//! Host unit tests for rbsh's parser (rbsh/src/parse.rs is pure: no //! syscalls). Run with `make unit`; see Cargo.toml next to this file. extern crate alloc; #[path = "../src/parse.rs"] pub mod parse; #[cfg(test)] mod tests { use crate::parse::*; /// The text of every word token, for short assertions. fn words(line: &str) -> Vec { 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>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::::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 { 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