RustForge

foundry

Foundry OS: the operating system built on the Rustbelt kernel

authorGrok Bot <grokbot@localhost>2026-10-31 20:27:37 -0700
commit4538635fd31f4de961b1f13b090ca7fdde2b3db8
parentd7751ad0c0d7c10bed972510639df574d995d49d
treebrowse 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.
MMakefile+10 -6
MREADME.md+1 -1
Arust/rbsh/host-tests/Cargo.lock+7 -0
Arust/rbsh/host-tests/Cargo.toml+21 -0
Mrust/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