foundry
Foundry OS: the operating system built on the Rustbelt kernel
| author | Grok Bot <grokbot@localhost> | 2026-10-31 20:27:37 -0700 |
|---|---|---|
| commit | 4538635fd31f4de961b1f13b090ca7fdde2b3db8 | |
| parent | d7751ad0c0d7c10bed972510639df574d995d49d | |
| tree | browse files at this commit | |
make unit: cargo test for rbsh's parser on the host (22 tests) rust/rbsh/host-tests is a tiny standalone crate that compiles rbsh/src/parse.rs for the build machine; 17 new tests cover tokenizing, quotes, comments, pipelines, redirections, & and the syntax errors, next to the 5 drive-letter tests. make rbsh-test is kept as an alias.
| M | Makefile | +10 -6 |
| M | README.md | +1 -1 |
| A | rust/rbsh/host-tests/Cargo.lock | +7 -0 |
| A | rust/rbsh/host-tests/Cargo.toml | +21 -0 |
| M | rust/rbsh/host-tests/parse_test.rs | +230 -49 |
5 files changed, 269 insertions, 56 deletions
@@ -28,7 +28,7 @@ PORTS_OUT := $(OUT)/ports DATA_BIN := $(filter-out $(RUST_PROGRAMS),$(BIN_ORDER)) .DEFAULT_GOAL := image-.PHONY: help image image-v1 image-v2 rust fixtures cprogs ports staging-v1 staging-v2 kernel kernel-src run test test-v1 test-v2 test-reference compare k1-check rbsh-test musl clean distclean+.PHONY: help image image-v1 image-v2 rust fixtures cprogs ports staging-v1 staging-v2 kernel kernel-src run test test-v1 test-v2 test-reference compare k1-check unit rbsh-test musl clean distclean help: @echo "Foundry OS (userland for the Rustbelt kernel $(KERNEL_VERSION) @ $(KERNEL_REV))"@@ -48,7 +48,7 @@ help: @echo " make test-v1 headless boot of the v1 image (the kernel runs the shell self-test)" @echo " make test-reference the v1 test, but booting the kernel's own disk.img (baseline)" @echo " make compare compare out/$(IMAGE_V1_NAME) with the kernel's own disk.img"- @echo " make rbsh-test host unit tests for rbsh's parser and drive letters (no QEMU)"+ @echo " make unit host unit tests (cargo test): rbsh's parser and drive letters, no QEMU" @echo " make k1-check build the kernel with RUSTBELT_DISK0=our image (K1) and check it boots ours" @echo " make musl fetch (cache/) + build musl $(MUSL_VERSION) into out/sysroot" @echo " make clean rm -rf out (distclean also removes cache/)"@@ -155,10 +155,14 @@ test-reference: kernel compare: image-v1 kernel FOUNDRY_BIN="$(FOUNDRY_C) $(FOUNDRY_PORTS)" FOUNDRY_FILES="$(FOUNDRY_FILES)" scripts/compare-image.sh $(IMAGE_V1) $(KTARGET)/disk.img $(OUT) -# rbsh's parser is pure Rust, so it is also tested on the host.-rbsh-test:- mkdir -p $(OUT)/host-tests- rustc --edition 2024 --test rust/rbsh/host-tests/parse_test.rs -o $(OUT)/host-tests/parse_test+# Host unit tests (`cargo test` on the build machine, no QEMU): rbsh's+# parser is pure Rust, so rust/rbsh/host-tests compiles it for the host.+# Run from the repository root so rust/.cargo/config.toml (which picks the+# Rustbelt target) does not apply.+unit:+ CARGO_TARGET_DIR=$(OUT)/host-target cargo test --manifest-path rust/rbsh/host-tests/Cargo.toml++rbsh-test: unit $(OUT)/host-tests/parse_test # K1 (kernel 0.21.0+): the kernel's own build takes our image as disk 0.
@@ -42,7 +42,7 @@ the packages the kernel needs plus `make` (all already on the box): | `make test-v1` | the contract-v1 test: the kernel runs the shell self-test on `out/foundry-v1.img` (~100 checks) | | `make test-reference` | the v1 test on the kernel's *own* disk image (the baseline) | | `make compare` | compares our v1 image with the kernel's, file by file |-| `make rbsh-test` | host unit tests for rbsh's parser and drive letters (no QEMU) |+| `make unit` | host unit tests (`cargo test` on the build machine, no QEMU): rbsh's parser and drive letters. `make rbsh-test` is the old name. | | `make k1-check` | builds the kernel with `RUSTBELT_DISK0=out/foundry.img` and checks it uses our image | | `make musl` | downloads (into `cache/`) and builds musl into `out/sysroot`; `make cprogs`/`make ports` run it when needed | | `make clean` | `rm -rf out` (`make distclean` also empties `cache/`) |
@@ -0,0 +1,7 @@+# This file is automatically @generated by Cargo.+# It is not intended for manual editing.+version = 4++[[package]]+name = "rbsh-host-tests"+version = "0.1.0"
@@ -0,0 +1,21 @@+# Host unit tests for rbsh's parser: `make unit` (or, from the repository+# root, `cargo test --manifest-path rust/rbsh/host-tests/Cargo.toml`).+#+# rbsh itself is a no_std program for x86_64-unknown-none, which cannot run+# tests on the build machine. Its parser (../src/parse.rs) makes no system+# calls, so this tiny crate compiles that same file for the host and tests it+# with the normal `cargo test`.+#+# The empty [workspace] keeps it out of rust/Cargo.toml's workspace, whose+# .cargo/config.toml would otherwise build it for the Rustbelt target.+[package]+name = "rbsh-host-tests"+version = "0.1.0"+edition = "2024"+publish = false++[lib]+path = "parse_test.rs"+doctest = false++[workspace]
@@ -1,53 +1,234 @@ //! Host unit tests for rbsh's parser (rbsh/src/parse.rs is pure: no-//! syscalls). Run with `make rbsh-test` (plain `rustc --test`, no Cargo-//! target needed).+//! syscalls). Run with `make unit`; see Cargo.toml next to this file. extern crate alloc; #[path = "../src/parse.rs"]-mod parse;--use parse::*;--fn letters(table: &str) -> Vec<String> {- assign_drives(table).iter().map(|d| format!("{}:{}", d.letter, d.path)).collect()-}--#[test]-fn contract_v1_drives() {- // Ramfs root, our FAT32 disk and the kernel's three test disks.- let t = "/ ramfs\n/tmp ramfs\n/mnt/disk0 fat32\n/mnt/disk1 ext2\n/mnt/disk2 exfat\n/mnt/disk3 btrfs\n";- assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/disk1", "D:/mnt/disk2", "E:/mnt/disk3"]);-}--#[test]-fn contract_v2_drives() {- // ext2 root, FAT32 data disk, exFAT share (mounted last by the kernel).- let t = "/ ext2\n/tmp ramfs\n/dev devfs\n/proc procfs\n/mnt/disk0 fat32\n/mnt/share exfat\n";- assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/share"]);- let d = assign_drives(t);- assert_eq!(d[0].fs, "ext2");- assert_eq!(d[2].fs, "exfat");-}--#[test]-fn share_skips_c_for_more_disks() {- let t = "/ ext2\n/mnt/disk0 fat32\n/mnt/disk1 ext2\n/mnt/disk2 btrfs\n/mnt/share exfat\n";- assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/share", "D:/mnt/disk1", "E:/mnt/disk2"]);-}--#[test]-fn share_without_disks_is_still_c() {- assert_eq!(letters("/ ext2\n/mnt/share exfat\n"), ["A:/", "C:/mnt/share"]);-}--#[test]-fn translate_and_display_with_share() {- let d = assign_drives("/ ext2\n/mnt/disk0 fat32\n/mnt/share exfat\n");- assert_eq!(translate("C:/notes.txt", &d).as_deref(), Some("/mnt/share/notes.txt"));- assert_eq!(translate("c:", &d).as_deref(), Some("/mnt/share"));- assert_eq!(translate("B:bin", &d).as_deref(), Some("/mnt/disk0/bin"));- assert_eq!(translate("A:/etc/motd", &d).as_deref(), Some("/etc/motd"));- assert_eq!(display("/mnt/share/x", &d), "C:/x");- assert_eq!(display("/mnt/disk0", &d), "B:/");- assert_eq!(display("/etc", &d), "A:/etc");-}+pub mod parse;++#[cfg(test)]+mod tests {+ use crate::parse::*;++ /// The text of every word token, for short assertions.+ fn words(line: &str) -> Vec<String> {+ tokenize(line)+ .unwrap()+ .into_iter()+ .filter_map(|t| match t {+ Token::Word(w) => Some(w.text),+ _ => None,+ })+ .collect()+ }++ /// Parse a line that must hold a pipeline.+ fn pipeline(line: &str) -> Pipeline {+ parse(line).unwrap().unwrap()+ }++ fn texts(c: &Command) -> Vec<&str> {+ c.words.iter().map(|w| w.text.as_str()).collect()+ }++ // --- tokenize ---------------------------------------------------------------++ #[test]+ fn splits_on_whitespace() {+ assert_eq!(words(" ls -l\tB:/bin "), ["ls", "-l", "B:/bin"]);+ }++ #[test]+ fn operators_need_no_spaces() {+ let t = tokenize("ls|wc>out<in>>log&").unwrap();+ let kinds: Vec<&str> = t+ .iter()+ .map(|t| match t {+ Token::Word(_) => "w",+ Token::Pipe => "|",+ Token::In => "<",+ Token::Out => ">",+ Token::Append => ">>",+ Token::Background => "&",+ })+ .collect();+ assert_eq!(kinds, ["w", "|", "w", ">", "w", "<", "w", ">>", "w", "&"]);+ }++ #[test]+ fn quotes_group_and_mark_words() {+ let t = tokenize("echo 'a b' \"c|d\" x\"y z\"w").unwrap();+ let w: Vec<(String, bool)> = t+ .into_iter()+ .map(|t| match t {+ Token::Word(w) => (w.text, w.quoted),+ other => panic!("unexpected {:?}", other),+ })+ .collect();+ assert_eq!(+ w,+ [+ ("echo".into(), false),+ ("a b".into(), true),+ ("c|d".into(), true), // | inside quotes is just text+ ("xy zw".into(), true), // one word mixing plain and quoted parts+ ]+ );+ }++ #[test]+ fn empty_quotes_make_an_empty_word() {+ assert_eq!(words("echo ''"), ["echo", ""]);+ }++ #[test]+ fn comments_end_the_line() {+ assert_eq!(words("echo hi # not this"), ["echo", "hi"]);+ assert_eq!(words("# only a comment"), Vec::<String>::new());+ // A # inside a word is text, not a comment.+ assert_eq!(words("echo a#b"), ["echo", "a#b"]);+ }++ #[test]+ fn unterminated_quote_is_an_error() {+ assert!(tokenize("echo 'oops").unwrap_err().contains("unterminated ' quote"));+ assert!(tokenize("echo \"oops").unwrap_err().contains("unterminated \" quote"));+ }++ // --- parse ------------------------------------------------------------------++ #[test]+ fn empty_line_and_comment_are_none() {+ assert_eq!(parse("").unwrap(), None);+ assert_eq!(parse(" ").unwrap(), None);+ assert_eq!(parse("# hi").unwrap(), None);+ }++ #[test]+ fn simple_command() {+ let p = pipeline("ls -l /bin");+ assert_eq!(p.commands.len(), 1);+ assert_eq!(texts(&p.commands[0]), ["ls", "-l", "/bin"]);+ assert!(!p.background);+ }++ #[test]+ fn three_stage_pipeline_with_redirections() {+ let p = pipeline("cat < in.txt | grep x | wc >> count.txt");+ assert_eq!(p.commands.len(), 3);+ assert_eq!(p.commands[0].stdin.as_ref().unwrap().text, "in.txt");+ assert_eq!(texts(&p.commands[1]), ["grep", "x"]);+ let (out, append) = p.commands[2].stdout.as_ref().unwrap();+ assert_eq!((out.text.as_str(), *append), ("count.txt", true));+ }++ #[test]+ fn truncating_redirect_and_last_one_wins() {+ let p = pipeline("echo hi > a > b");+ let (out, append) = p.commands[0].stdout.as_ref().unwrap();+ assert_eq!((out.text.as_str(), *append), ("b", false));+ }++ #[test]+ fn redirection_may_come_first() {+ let p = pipeline("> out.txt echo hi");+ assert_eq!(texts(&p.commands[0]), ["echo", "hi"]);+ assert_eq!(p.commands[0].stdout.as_ref().unwrap().0.text, "out.txt");+ }++ #[test]+ fn background() {+ let p = pipeline("sleep 5 &");+ assert!(p.background);+ assert_eq!(texts(&p.commands[0]), ["sleep", "5"]);+ }++ #[test]+ fn syntax_errors() {+ assert_eq!(parse("| wc").unwrap_err(), "missing command before `|`");+ assert_eq!(parse("ls |").unwrap_err(), "missing command after `|`");+ assert_eq!(parse("ls | | wc").unwrap_err(), "missing command before `|`");+ assert_eq!(parse("> out").unwrap_err(), "missing command");+ assert_eq!(parse("&").unwrap_err(), "missing command");+ assert_eq!(parse("ls >").unwrap_err(), "missing file name after redirection");+ assert_eq!(parse("ls > | wc").unwrap_err(), "missing file name after redirection");+ assert_eq!(parse("sleep 1 & ls").unwrap_err(), "`&` must be at the end of the line");+ }++ // --- drive letters ----------------------------------------------------------++ #[test]+ fn drive_letter_detection() {+ assert_eq!(drive_letter("B:"), Some('B'));+ assert_eq!(drive_letter("c:/x"), Some('C'));+ assert_eq!(drive_letter("C:notes"), Some('C'));+ assert_eq!(drive_letter("B"), None);+ assert_eq!(drive_letter("1:"), None);+ assert_eq!(drive_letter("/mnt"), None);+ assert_eq!(drive_letter(""), None);+ }++ #[test]+ fn unknown_drive_does_not_translate() {+ let d = assign_drives("/ ext2\n/mnt/disk0 fat32\n");+ assert_eq!(translate("Z:/x", &d), None);+ assert_eq!(translate("plain", &d), None);+ }++ #[test]+ fn display_picks_the_longest_match() {+ let d = assign_drives("/ ext2\n/mnt/disk0 fat32\n");+ assert_eq!(display("/mnt/disk0/bin", &d), "B:/bin");+ assert_eq!(display("/mnt/disk01", &d), "A:/mnt/disk01"); // not B:+ assert_eq!(display("/", &d), "A:/");+ }++ #[test]+ fn no_root_line_means_ramfs() {+ let d = assign_drives("/mnt/disk0 fat32\n");+ assert_eq!(d[0].fs, "ramfs");+ }++ fn letters(table: &str) -> Vec<String> {+ assign_drives(table).iter().map(|d| format!("{}:{}", d.letter, d.path)).collect()+ }++ #[test]+ fn contract_v1_drives() {+ // Ramfs root, our FAT32 disk and the kernel's three test disks.+ let t = "/ ramfs\n/tmp ramfs\n/mnt/disk0 fat32\n/mnt/disk1 ext2\n/mnt/disk2 exfat\n/mnt/disk3 btrfs\n";+ assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/disk1", "D:/mnt/disk2", "E:/mnt/disk3"]);+ }++ #[test]+ fn contract_v2_drives() {+ // ext2 root, FAT32 data disk, exFAT share (mounted last by the kernel).+ let t = "/ ext2\n/tmp ramfs\n/dev devfs\n/proc procfs\n/mnt/disk0 fat32\n/mnt/share exfat\n";+ assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/share"]);+ let d = assign_drives(t);+ assert_eq!(d[0].fs, "ext2");+ assert_eq!(d[2].fs, "exfat");+ }++ #[test]+ fn share_skips_c_for_more_disks() {+ let t = "/ ext2\n/mnt/disk0 fat32\n/mnt/disk1 ext2\n/mnt/disk2 btrfs\n/mnt/share exfat\n";+ assert_eq!(letters(t), ["A:/", "B:/mnt/disk0", "C:/mnt/share", "D:/mnt/disk1", "E:/mnt/disk2"]);+ }++ #[test]+ fn share_without_disks_is_still_c() {+ assert_eq!(letters("/ ext2\n/mnt/share exfat\n"), ["A:/", "C:/mnt/share"]);+ }++ #[test]+ fn translate_and_display_with_share() {+ let d = assign_drives("/ ext2\n/mnt/disk0 fat32\n/mnt/share exfat\n");+ assert_eq!(translate("C:/notes.txt", &d).as_deref(), Some("/mnt/share/notes.txt"));+ assert_eq!(translate("c:", &d).as_deref(), Some("/mnt/share"));+ assert_eq!(translate("B:bin", &d).as_deref(), Some("/mnt/disk0/bin"));+ assert_eq!(translate("A:/etc/motd", &d).as_deref(), Some("/etc/motd"));+ assert_eq!(display("/mnt/share/x", &d), "C:/x");+ assert_eq!(display("/mnt/disk0", &d), "B:/");+ assert_eq!(display("/etc", &d), "A:/etc");+ }+} // mod tests