Skip to main content

rust_bash/
api.rs

1//! Public API: `RustBash` shell instance and builder.
2
3use crate::commands::{self, VirtualCommand};
4use crate::error::RustBashError;
5use crate::interpreter::{
6    self, ExecResult, ExecutionCounters, ExecutionLimits, InterpreterState, ShellOpts, ShoptOpts,
7    Variable, VariableAttrs, VariableValue,
8};
9use crate::network::NetworkPolicy;
10use crate::platform::Instant;
11use crate::vfs::{InMemoryFs, VirtualFs};
12use std::collections::HashMap;
13use std::path::Path;
14use std::sync::Arc;
15
16/// A sandboxed bash shell interpreter.
17pub struct RustBash {
18    pub(crate) state: InterpreterState,
19}
20
21impl RustBash {
22    /// Execute a shell command string and return the result.
23    pub fn exec(&mut self, input: &str) -> Result<ExecResult, RustBashError> {
24        self.state.counters.reset();
25        self.state.should_exit = false;
26
27        let program = match interpreter::parse(input) {
28            Ok(p) => p,
29            Err(e) => {
30                self.state.last_exit_code = 2;
31                return Err(e);
32            }
33        };
34        let mut result = interpreter::execute_program(&program, &mut self.state)?;
35
36        // Fire EXIT trap at end of exec()
37        if let Some(exit_cmd) = self.state.traps.get("EXIT").cloned()
38            && !exit_cmd.is_empty()
39            && !self.state.in_trap
40        {
41            let trap_result = interpreter::execute_trap(&exit_cmd, &mut self.state)?;
42            result.stdout.push_str(&trap_result.stdout);
43            result.stderr.push_str(&trap_result.stderr);
44        }
45
46        Ok(result)
47    }
48
49    /// Returns the current working directory.
50    pub fn cwd(&self) -> &str {
51        &self.state.cwd
52    }
53
54    /// Returns the exit code of the last executed command.
55    pub fn last_exit_code(&self) -> i32 {
56        self.state.last_exit_code
57    }
58
59    /// Returns `true` if the shell received an `exit` command.
60    pub fn should_exit(&self) -> bool {
61        self.state.should_exit
62    }
63
64    /// Returns the names of all registered commands (builtins + custom).
65    pub fn command_names(&self) -> Vec<&str> {
66        self.state.commands.keys().map(|k| k.as_str()).collect()
67    }
68
69    /// Returns the `CommandMeta` for a registered command, if it provides one.
70    pub fn command_meta(&self, name: &str) -> Option<&'static commands::CommandMeta> {
71        self.state.commands.get(name).and_then(|cmd| cmd.meta())
72    }
73
74    /// Sets the shell name (`$0`).
75    pub fn set_shell_name(&mut self, name: String) {
76        self.state.shell_name = name;
77    }
78
79    /// Sets the positional parameters (`$1`, `$2`, ...).
80    pub fn set_positional_params(&mut self, params: Vec<String>) {
81        self.state.positional_params = params;
82    }
83
84    // ── VFS convenience methods ──────────────────────────────────────
85
86    /// Returns a reference to the virtual filesystem.
87    pub fn fs(&self) -> &Arc<dyn crate::vfs::VirtualFs> {
88        &self.state.fs
89    }
90
91    /// Write a file to the virtual filesystem, creating parent directories.
92    pub fn write_file(&self, path: &str, content: &[u8]) -> Result<(), crate::VfsError> {
93        let p = Path::new(path);
94        if let Some(parent) = p.parent()
95            && parent != Path::new("/")
96        {
97            self.state.fs.mkdir_p(parent)?;
98        }
99        self.state.fs.write_file(p, content)
100    }
101
102    /// Read a file from the virtual filesystem.
103    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, crate::VfsError> {
104        self.state.fs.read_file(Path::new(path))
105    }
106
107    /// Create a directory in the virtual filesystem.
108    pub fn mkdir(&self, path: &str, recursive: bool) -> Result<(), crate::VfsError> {
109        let p = Path::new(path);
110        if recursive {
111            self.state.fs.mkdir_p(p)
112        } else {
113            self.state.fs.mkdir(p)
114        }
115    }
116
117    /// Check if a path exists in the virtual filesystem.
118    pub fn exists(&self, path: &str) -> bool {
119        self.state.fs.exists(Path::new(path))
120    }
121
122    /// List entries in a directory.
123    pub fn readdir(&self, path: &str) -> Result<Vec<crate::vfs::DirEntry>, crate::VfsError> {
124        self.state.fs.readdir(Path::new(path))
125    }
126
127    /// Get metadata for a path.
128    pub fn stat(&self, path: &str) -> Result<crate::vfs::Metadata, crate::VfsError> {
129        self.state.fs.stat(Path::new(path))
130    }
131
132    /// Remove a file from the virtual filesystem.
133    pub fn remove_file(&self, path: &str) -> Result<(), crate::VfsError> {
134        self.state.fs.remove_file(Path::new(path))
135    }
136
137    /// Remove a directory (and contents if recursive) from the virtual filesystem.
138    pub fn remove_dir_all(&self, path: &str) -> Result<(), crate::VfsError> {
139        self.state.fs.remove_dir_all(Path::new(path))
140    }
141
142    /// Register a custom command.
143    pub fn register_command(&mut self, cmd: Box<dyn VirtualCommand>) {
144        self.state.commands.insert(cmd.name().to_string(), cmd);
145    }
146
147    /// Execute a command with per-exec environment and cwd overrides.
148    ///
149    /// Overrides are applied before execution and restored afterward.
150    pub fn exec_with_overrides(
151        &mut self,
152        input: &str,
153        env: Option<&HashMap<String, String>>,
154        cwd: Option<&str>,
155        stdin: Option<&str>,
156    ) -> Result<ExecResult, RustBashError> {
157        let saved_cwd = self.state.cwd.clone();
158        let mut overwritten_env: Vec<(String, Option<Variable>)> = Vec::new();
159
160        if let Some(env) = env {
161            for (key, value) in env {
162                let old = self.state.env.get(key).cloned();
163                overwritten_env.push((key.clone(), old));
164                self.state.env.insert(
165                    key.clone(),
166                    Variable {
167                        value: VariableValue::Scalar(value.clone()),
168                        attrs: VariableAttrs::EXPORTED,
169                    },
170                );
171            }
172        }
173
174        if let Some(cwd) = cwd {
175            self.state.cwd = cwd.to_string();
176        }
177
178        let result = if let Some(stdin) = stdin {
179            let delimiter = if stdin.contains("__EXEC_STDIN__") {
180                "__EXEC_STDIN_BOUNDARY__"
181            } else {
182                "__EXEC_STDIN__"
183            };
184            let full_command = format!("{input} <<'{delimiter}'\n{stdin}\n{delimiter}");
185            self.exec(&full_command)
186        } else {
187            self.exec(input)
188        };
189
190        // Restore state
191        self.state.cwd = saved_cwd;
192        for (key, old_val) in overwritten_env {
193            match old_val {
194                Some(var) => {
195                    self.state.env.insert(key, var);
196                }
197                None => {
198                    self.state.env.remove(&key);
199                }
200            }
201        }
202
203        result
204    }
205
206    /// Check whether `input` looks like a complete shell statement.
207    ///
208    /// Returns `true` when the input can be tokenized and parsed without
209    /// hitting an "unexpected end-of-input" / unterminated-quote error.
210    /// Useful for implementing multi-line REPL input.
211    ///
212    /// Note: mirrors the tokenize → parse flow from `interpreter::parse()`.
213    pub fn is_input_complete(input: &str) -> bool {
214        match brush_parser::tokenize_str(input) {
215            Err(e) if e.is_incomplete() => false,
216            Err(_) => true, // genuine syntax error, not incomplete
217            Ok(tokens) => {
218                if tokens.is_empty() {
219                    return true;
220                }
221                let options = interpreter::parser_options();
222                let source_info = brush_parser::SourceInfo {
223                    source: input.to_string(),
224                };
225                match brush_parser::parse_tokens(&tokens, &options, &source_info) {
226                    Ok(_) => true,
227                    Err(brush_parser::ParseError::ParsingAtEndOfInput) => false,
228                    Err(_) => true, // genuine syntax error
229                }
230            }
231        }
232    }
233}
234
235/// Builder for configuring a [`RustBash`] instance.
236pub struct RustBashBuilder {
237    files: HashMap<String, Vec<u8>>,
238    env: HashMap<String, String>,
239    cwd: Option<String>,
240    custom_commands: Vec<Box<dyn VirtualCommand>>,
241    limits: Option<ExecutionLimits>,
242    network_policy: Option<NetworkPolicy>,
243    fs: Option<Arc<dyn VirtualFs>>,
244}
245
246impl Default for RustBashBuilder {
247    fn default() -> Self {
248        Self::new()
249    }
250}
251
252impl RustBashBuilder {
253    /// Create a new builder with default settings.
254    pub fn new() -> Self {
255        Self {
256            files: HashMap::new(),
257            env: HashMap::new(),
258            cwd: None,
259            custom_commands: Vec::new(),
260            limits: None,
261            network_policy: None,
262            fs: None,
263        }
264    }
265
266    /// Pre-populate the virtual filesystem with files.
267    pub fn files(mut self, files: HashMap<String, Vec<u8>>) -> Self {
268        self.files = files;
269        self
270    }
271
272    /// Set environment variables.
273    pub fn env(mut self, env: HashMap<String, String>) -> Self {
274        self.env = env;
275        self
276    }
277
278    /// Set the initial working directory (created automatically).
279    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
280        self.cwd = Some(cwd.into());
281        self
282    }
283
284    /// Register a custom command.
285    pub fn command(mut self, cmd: Box<dyn VirtualCommand>) -> Self {
286        self.custom_commands.push(cmd);
287        self
288    }
289
290    /// Override the default execution limits.
291    pub fn execution_limits(mut self, limits: ExecutionLimits) -> Self {
292        self.limits = Some(limits);
293        self
294    }
295
296    /// Set the maximum number of elements allowed in a single array.
297    pub fn max_array_elements(mut self, max: usize) -> Self {
298        let mut limits = self.limits.unwrap_or_default();
299        limits.max_array_elements = max;
300        self.limits = Some(limits);
301        self
302    }
303
304    /// Override the default network policy.
305    pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {
306        self.network_policy = Some(policy);
307        self
308    }
309
310    /// Use a custom filesystem backend instead of the default InMemoryFs.
311    ///
312    /// When set, the builder uses this filesystem directly. The `.files()` method
313    /// still works — it writes seed files into the provided backend via VirtualFs
314    /// methods.
315    pub fn fs(mut self, fs: Arc<dyn VirtualFs>) -> Self {
316        self.fs = Some(fs);
317        self
318    }
319
320    /// Build the shell instance.
321    pub fn build(self) -> Result<RustBash, RustBashError> {
322        let fs: Arc<dyn VirtualFs> = self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new()));
323        let cwd = self.cwd.unwrap_or_else(|| "/".to_string());
324        fs.mkdir_p(Path::new(&cwd))?;
325
326        for (path, content) in &self.files {
327            let p = Path::new(path);
328            if let Some(parent) = p.parent()
329                && parent != Path::new("/")
330            {
331                fs.mkdir_p(parent)?;
332            }
333            fs.write_file(p, content)?;
334        }
335
336        let mut commands = commands::register_default_commands();
337        for cmd in self.custom_commands {
338            commands.insert(cmd.name().to_string(), cmd);
339        }
340
341        // Insert default environment variables (caller-provided values take precedence)
342        let mut env_map = self.env;
343        let defaults: &[(&str, &str)] = &[
344            ("PATH", "/usr/bin:/bin"),
345            ("HOME", "/home/user"),
346            ("USER", "user"),
347            ("HOSTNAME", "rust-bash"),
348            ("OSTYPE", "linux-gnu"),
349            ("SHELL", "/bin/bash"),
350            ("BASH", "/bin/bash"),
351            ("BASH_VERSION", env!("CARGO_PKG_VERSION")),
352            ("OLDPWD", ""),
353            ("TERM", "xterm-256color"),
354        ];
355        for &(key, value) in defaults {
356            env_map
357                .entry(key.to_string())
358                .or_insert_with(|| value.to_string());
359        }
360        env_map
361            .entry("PWD".to_string())
362            .or_insert_with(|| cwd.clone());
363
364        setup_default_filesystem(fs.as_ref(), &env_map, &commands)?;
365
366        let mut env: HashMap<String, Variable> = env_map
367            .into_iter()
368            .map(|(k, v)| {
369                (
370                    k,
371                    Variable {
372                        value: VariableValue::Scalar(v),
373                        attrs: VariableAttrs::EXPORTED,
374                    },
375                )
376            })
377            .collect();
378
379        // Non-exported shell variables with default values
380        for (name, val) in &[("OPTIND", "1"), ("OPTERR", "1")] {
381            env.entry(name.to_string()).or_insert_with(|| Variable {
382                value: VariableValue::Scalar(val.to_string()),
383                attrs: VariableAttrs::empty(),
384            });
385        }
386
387        let mut state = InterpreterState {
388            fs,
389            env,
390            cwd,
391            functions: HashMap::new(),
392            last_exit_code: 0,
393            commands,
394            shell_opts: ShellOpts::default(),
395            shopt_opts: ShoptOpts::default(),
396            limits: self.limits.unwrap_or_default(),
397            counters: ExecutionCounters::default(),
398            network_policy: self.network_policy.unwrap_or_default(),
399            should_exit: false,
400            loop_depth: 0,
401            control_flow: None,
402            positional_params: Vec::new(),
403            shell_name: "rust-bash".to_string(),
404            random_seed: 0,
405            local_scopes: Vec::new(),
406            in_function_depth: 0,
407            traps: HashMap::new(),
408            in_trap: false,
409            errexit_suppressed: 0,
410            stdin_offset: 0,
411            dir_stack: Vec::new(),
412            command_hash: HashMap::new(),
413            aliases: HashMap::new(),
414            current_lineno: 0,
415            shell_start_time: Instant::now(),
416            last_argument: String::new(),
417            call_stack: Vec::new(),
418            machtype: "x86_64-pc-linux-gnu".to_string(),
419            hosttype: "x86_64".to_string(),
420            persistent_fds: HashMap::new(),
421            next_auto_fd: 10,
422            proc_sub_counter: 0,
423            proc_sub_prealloc: HashMap::new(),
424            pipe_stdin_bytes: None,
425        };
426
427        // Set SHELLOPTS and BASHOPTS as readonly variables
428        // They are computed dynamically on read, but must exist so `test -v` works.
429        state.env.insert(
430            "SHELLOPTS".to_string(),
431            Variable {
432                value: VariableValue::Scalar(String::new()),
433                attrs: VariableAttrs::READONLY,
434            },
435        );
436        state.env.insert(
437            "BASHOPTS".to_string(),
438            Variable {
439                value: VariableValue::Scalar(String::new()),
440                attrs: VariableAttrs::READONLY,
441            },
442        );
443
444        // PS4 defaults to "+ " (xtrace prefix); explicit `unset PS4` removes it.
445        state.env.insert(
446            "PS4".to_string(),
447            Variable {
448                value: VariableValue::Scalar("+ ".to_string()),
449                attrs: VariableAttrs::empty(),
450            },
451        );
452
453        Ok(RustBash { state })
454    }
455}
456
457/// Create standard directories and command stubs in the VFS.
458///
459/// Directories and files are only created when they don't already exist,
460/// so user-seeded content is never clobbered.
461fn setup_default_filesystem(
462    fs: &dyn VirtualFs,
463    env: &HashMap<String, String>,
464    commands: &HashMap<String, Box<dyn commands::VirtualCommand>>,
465) -> Result<(), RustBashError> {
466    // Standard directories
467    for dir in &["/bin", "/usr/bin", "/tmp", "/dev"] {
468        let _ = fs.mkdir_p(Path::new(dir));
469    }
470
471    // HOME directory
472    if let Some(home) = env.get("HOME") {
473        let _ = fs.mkdir_p(Path::new(home));
474    }
475
476    // /dev special files
477    for name in &["null", "zero", "stdin", "stdout", "stderr"] {
478        let path_str = format!("/dev/{name}");
479        let p = Path::new(&path_str);
480        if !fs.exists(p) {
481            fs.write_file(p, b"")?;
482        }
483    }
484
485    // Command stubs in /bin/ for each registered command
486    for name in commands.keys() {
487        let path_str = format!("/bin/{name}");
488        let p = Path::new(&path_str);
489        if !fs.exists(p) {
490            let content = format!("#!/bin/bash\n# built-in: {name}\n");
491            fs.write_file(p, content.as_bytes())?;
492        }
493    }
494
495    // Builtin stubs in /bin/ (skip names unsuitable as filenames)
496    for &name in interpreter::builtins::builtin_names() {
497        if matches!(name, "." | ":" | "colon") {
498            continue;
499        }
500        let path_str = format!("/bin/{name}");
501        let p = Path::new(&path_str);
502        if !fs.exists(p) {
503            let content = format!("#!/bin/bash\n# built-in: {name}\n");
504            fs.write_file(p, content.as_bytes())?;
505        }
506    }
507
508    Ok(())
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn shell() -> RustBash {
516        RustBashBuilder::new().build().unwrap()
517    }
518
519    // ── Exit criteria ───────────────────────────────────────────
520
521    #[test]
522    fn echo_hello_end_to_end() {
523        let mut shell = shell();
524        let result = shell.exec("echo hello").unwrap();
525        assert_eq!(result.stdout, "hello\n");
526        assert_eq!(result.exit_code, 0);
527        assert_eq!(result.stderr, "");
528    }
529
530    // ── Echo variants ───────────────────────────────────────────
531
532    #[test]
533    fn echo_multiple_words() {
534        let mut shell = shell();
535        let result = shell.exec("echo hello world").unwrap();
536        assert_eq!(result.stdout, "hello world\n");
537    }
538
539    #[test]
540    fn echo_no_args() {
541        let mut shell = shell();
542        let result = shell.exec("echo").unwrap();
543        assert_eq!(result.stdout, "\n");
544    }
545
546    #[test]
547    fn echo_no_newline() {
548        let mut shell = shell();
549        let result = shell.exec("echo -n hello").unwrap();
550        assert_eq!(result.stdout, "hello");
551    }
552
553    #[test]
554    fn echo_escape_sequences() {
555        let mut shell = shell();
556        let result = shell.exec(r"echo -e 'hello\nworld'").unwrap();
557        assert_eq!(result.stdout, "hello\nworld\n");
558    }
559
560    // ── true / false ────────────────────────────────────────────
561
562    #[test]
563    fn true_command() {
564        let mut shell = shell();
565        let result = shell.exec("true").unwrap();
566        assert_eq!(result.exit_code, 0);
567        assert_eq!(result.stdout, "");
568    }
569
570    #[test]
571    fn false_command() {
572        let mut shell = shell();
573        let result = shell.exec("false").unwrap();
574        assert_eq!(result.exit_code, 1);
575    }
576
577    // ── exit ────────────────────────────────────────────────────
578
579    #[test]
580    fn exit_default_code() {
581        let mut shell = shell();
582        let result = shell.exec("exit").unwrap();
583        assert_eq!(result.exit_code, 0);
584    }
585
586    #[test]
587    fn exit_with_code() {
588        let mut shell = shell();
589        let result = shell.exec("exit 42").unwrap();
590        assert_eq!(result.exit_code, 42);
591    }
592
593    #[test]
594    fn exit_stops_subsequent_commands() {
595        let mut shell = shell();
596        let result = shell.exec("exit 1; echo should_not_appear").unwrap();
597        assert_eq!(result.exit_code, 1);
598        assert!(!result.stdout.contains("should_not_appear"));
599    }
600
601    #[test]
602    fn exit_non_numeric_argument() {
603        let mut shell = shell();
604        let result = shell.exec("exit foo").unwrap();
605        assert_eq!(result.exit_code, 2);
606        assert!(result.stderr.contains("numeric argument required"));
607    }
608
609    // ── Command not found ───────────────────────────────────────
610
611    #[test]
612    fn command_not_found() {
613        let mut shell = shell();
614        let result = shell.exec("nonexistent_cmd").unwrap();
615        assert_eq!(result.exit_code, 127);
616        assert!(result.stderr.contains("command not found"));
617    }
618
619    // ── Sequential commands ─────────────────────────────────────
620
621    #[test]
622    fn sequential_commands() {
623        let mut shell = shell();
624        let result = shell.exec("echo hello; echo world").unwrap();
625        assert_eq!(result.stdout, "hello\nworld\n");
626    }
627
628    #[test]
629    fn sequential_exit_code_is_last() {
630        let mut shell = shell();
631        let result = shell.exec("true; false").unwrap();
632        assert_eq!(result.exit_code, 1);
633    }
634
635    // ── And-or lists ────────────────────────────────────────────
636
637    #[test]
638    fn and_success() {
639        let mut shell = shell();
640        let result = shell.exec("true && echo yes").unwrap();
641        assert_eq!(result.stdout, "yes\n");
642    }
643
644    #[test]
645    fn and_failure_skips() {
646        let mut shell = shell();
647        let result = shell.exec("false && echo yes").unwrap();
648        assert_eq!(result.stdout, "");
649        assert_eq!(result.exit_code, 1);
650    }
651
652    #[test]
653    fn or_success_skips() {
654        let mut shell = shell();
655        let result = shell.exec("true || echo no").unwrap();
656        assert_eq!(result.stdout, "");
657        assert_eq!(result.exit_code, 0);
658    }
659
660    #[test]
661    fn or_failure_runs() {
662        let mut shell = shell();
663        let result = shell.exec("false || echo yes").unwrap();
664        assert_eq!(result.stdout, "yes\n");
665        assert_eq!(result.exit_code, 0);
666    }
667
668    #[test]
669    fn chained_and_or() {
670        let mut shell = shell();
671        let result = shell.exec("false || true && echo yes").unwrap();
672        assert_eq!(result.stdout, "yes\n");
673        assert_eq!(result.exit_code, 0);
674    }
675
676    // ── Pipeline negation ───────────────────────────────────────
677
678    #[test]
679    fn pipeline_negation_true() {
680        let mut shell = shell();
681        let result = shell.exec("! true").unwrap();
682        assert_eq!(result.exit_code, 1);
683    }
684
685    #[test]
686    fn pipeline_negation_false() {
687        let mut shell = shell();
688        let result = shell.exec("! false").unwrap();
689        assert_eq!(result.exit_code, 0);
690    }
691
692    // ── Variable assignment ─────────────────────────────────────
693
694    #[test]
695    fn bare_assignment() {
696        let mut shell = shell();
697        let result = shell.exec("FOO=bar").unwrap();
698        assert_eq!(result.exit_code, 0);
699        assert_eq!(shell.state.env.get("FOO").unwrap().value.as_scalar(), "bar");
700    }
701
702    // ── State persistence ───────────────────────────────────────
703
704    #[test]
705    fn state_persists_across_exec_calls() {
706        let mut shell = shell();
707        shell.exec("FOO=hello").unwrap();
708        assert_eq!(
709            shell.state.env.get("FOO").unwrap().value.as_scalar(),
710            "hello"
711        );
712        let result = shell.exec("true").unwrap();
713        assert_eq!(result.exit_code, 0);
714        assert_eq!(
715            shell.state.env.get("FOO").unwrap().value.as_scalar(),
716            "hello"
717        );
718    }
719
720    // ── Empty / whitespace input ────────────────────────────────
721
722    #[test]
723    fn empty_input() {
724        let mut shell = shell();
725        let result = shell.exec("").unwrap();
726        assert_eq!(result.stdout, "");
727        assert_eq!(result.exit_code, 0);
728    }
729
730    #[test]
731    fn whitespace_only_input() {
732        let mut shell = shell();
733        let result = shell.exec("   ").unwrap();
734        assert_eq!(result.stdout, "");
735        assert_eq!(result.exit_code, 0);
736    }
737
738    // ── Builder ─────────────────────────────────────────────────
739
740    #[test]
741    fn builder_default_cwd() {
742        let shell = RustBashBuilder::new().build().unwrap();
743        assert_eq!(shell.state.cwd, "/");
744    }
745
746    #[test]
747    fn builder_with_cwd() {
748        let shell = RustBashBuilder::new().cwd("/home/user").build().unwrap();
749        assert_eq!(shell.state.cwd, "/home/user");
750    }
751
752    #[test]
753    fn builder_with_env() {
754        let mut env = HashMap::new();
755        env.insert("HOME".to_string(), "/home/test".to_string());
756        let shell = RustBashBuilder::new().env(env).build().unwrap();
757        assert_eq!(
758            shell.state.env.get("HOME").unwrap().value.as_scalar(),
759            "/home/test"
760        );
761    }
762
763    #[test]
764    fn builder_with_files() {
765        let mut files = HashMap::new();
766        files.insert("/etc/test.txt".to_string(), b"hello".to_vec());
767        let shell = RustBashBuilder::new().files(files).build().unwrap();
768        let content = shell
769            .state
770            .fs
771            .read_file(Path::new("/etc/test.txt"))
772            .unwrap();
773        assert_eq!(content, b"hello");
774    }
775
776    #[test]
777    fn builder_with_custom_command() {
778        use crate::commands::{CommandContext, CommandResult, VirtualCommand};
779
780        struct CustomCmd;
781        impl VirtualCommand for CustomCmd {
782            fn name(&self) -> &str {
783                "custom"
784            }
785            fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {
786                CommandResult {
787                    stdout: "custom output\n".to_string(),
788                    ..CommandResult::default()
789                }
790            }
791        }
792
793        let mut shell = RustBashBuilder::new()
794            .command(Box::new(CustomCmd))
795            .build()
796            .unwrap();
797        let result = shell.exec("custom").unwrap();
798        assert_eq!(result.stdout, "custom output\n");
799    }
800
801    // ── Additional edge cases ───────────────────────────────────
802
803    #[test]
804    fn exit_wraps_to_byte_range() {
805        let mut shell = shell();
806        let result = shell.exec("exit 256").unwrap();
807        assert_eq!(result.exit_code, 0);
808    }
809
810    #[test]
811    fn multiple_bare_assignments() {
812        let mut shell = shell();
813        shell.exec("A=1 B=2").unwrap();
814        assert_eq!(shell.state.env.get("A").unwrap().value.as_scalar(), "1");
815        assert_eq!(shell.state.env.get("B").unwrap().value.as_scalar(), "2");
816    }
817
818    #[test]
819    fn comment_stripping() {
820        let mut shell = shell();
821        let result = shell.exec("echo hello # this is a comment").unwrap();
822        assert_eq!(result.stdout, "hello\n");
823    }
824
825    #[test]
826    fn negation_with_and_or() {
827        let mut shell = shell();
828        let result = shell.exec("! false && echo yes").unwrap();
829        assert_eq!(result.stdout, "yes\n");
830        assert_eq!(result.exit_code, 0);
831    }
832
833    #[test]
834    fn deeply_chained_and_or() {
835        let mut shell = shell();
836        let result = shell.exec("true && false || true && echo yes").unwrap();
837        assert_eq!(result.stdout, "yes\n");
838        assert_eq!(result.exit_code, 0);
839    }
840
841    // ── Variable expansion (Phase 1B) ──────────────────────────────
842
843    #[test]
844    fn expand_simple_variable() {
845        let mut shell = shell();
846        shell.exec("FOO=bar").unwrap();
847        let result = shell.exec("echo $FOO").unwrap();
848        assert_eq!(result.stdout, "bar\n");
849    }
850
851    #[test]
852    fn expand_braced_variable() {
853        let mut shell = shell();
854        shell.exec("FOO=bar").unwrap();
855        let result = shell.exec("echo ${FOO}").unwrap();
856        assert_eq!(result.stdout, "bar\n");
857    }
858
859    #[test]
860    fn expand_unset_variable_is_empty() {
861        let mut shell = shell();
862        let result = shell.exec("echo \"$UNDEFINED\"").unwrap();
863        assert_eq!(result.stdout, "\n");
864    }
865
866    #[test]
867    fn expand_default_value() {
868        let mut shell = shell();
869        let result = shell.exec("echo ${UNSET:-default}").unwrap();
870        assert_eq!(result.stdout, "default\n");
871    }
872
873    #[test]
874    fn expand_default_not_used_when_set() {
875        let mut shell = shell();
876        shell.exec("VAR=hello").unwrap();
877        let result = shell.exec("echo ${VAR:-default}").unwrap();
878        assert_eq!(result.stdout, "hello\n");
879    }
880
881    #[test]
882    fn expand_assign_default() {
883        let mut shell = shell();
884        let result = shell.exec("echo ${UNSET:=fallback}").unwrap();
885        assert_eq!(result.stdout, "fallback\n");
886        assert_eq!(
887            shell.state.env.get("UNSET").unwrap().value.as_scalar(),
888            "fallback"
889        );
890    }
891
892    #[test]
893    fn expand_default_with_variable() {
894        let mut shell = shell();
895        shell.exec("FALLBACK=resolved").unwrap();
896        let result = shell.exec("echo ${UNSET:-$FALLBACK}").unwrap();
897        assert_eq!(result.stdout, "resolved\n");
898    }
899
900    #[test]
901    fn expand_error_if_unset() {
902        let mut shell = shell();
903        let result = shell.exec("echo ${UNSET:?missing var}").unwrap();
904        assert_eq!(result.exit_code, 127);
905        assert!(result.stderr.contains("missing var"));
906        assert!(result.stdout.is_empty());
907    }
908
909    #[test]
910    fn expand_alternative_value() {
911        let mut shell = shell();
912        shell.exec("VAR=hello").unwrap();
913        let result = shell.exec("echo ${VAR:+alt}").unwrap();
914        assert_eq!(result.stdout, "alt\n");
915    }
916
917    #[test]
918    fn expand_alternative_unset_is_empty() {
919        let mut shell = shell();
920        let result = shell.exec("echo \"${UNSET:+alt}\"").unwrap();
921        assert_eq!(result.stdout, "\n");
922    }
923
924    #[test]
925    fn expand_string_length() {
926        let mut shell = shell();
927        shell.exec("VAR=hello").unwrap();
928        let result = shell.exec("echo ${#VAR}").unwrap();
929        assert_eq!(result.stdout, "5\n");
930    }
931
932    #[test]
933    fn expand_suffix_removal_shortest() {
934        let mut shell = shell();
935        shell.exec("FILE=hello.tar.gz").unwrap();
936        let result = shell.exec("echo ${FILE%.*}").unwrap();
937        assert_eq!(result.stdout, "hello.tar\n");
938    }
939
940    #[test]
941    fn expand_suffix_removal_longest() {
942        let mut shell = shell();
943        shell.exec("FILE=hello.tar.gz").unwrap();
944        let result = shell.exec("echo ${FILE%%.*}").unwrap();
945        assert_eq!(result.stdout, "hello\n");
946    }
947
948    #[test]
949    fn expand_prefix_removal_shortest() {
950        let mut shell = shell();
951        shell.exec("PATH_VAR=/a/b/c").unwrap();
952        let result = shell.exec("echo ${PATH_VAR#*/}").unwrap();
953        assert_eq!(result.stdout, "a/b/c\n");
954    }
955
956    #[test]
957    fn expand_prefix_removal_longest() {
958        let mut shell = shell();
959        shell.exec("PATH_VAR=/a/b/c").unwrap();
960        let result = shell.exec("echo ${PATH_VAR##*/}").unwrap();
961        assert_eq!(result.stdout, "c\n");
962    }
963
964    #[test]
965    fn expand_substitution_first() {
966        let mut shell = shell();
967        shell.exec("STR=hello").unwrap();
968        let result = shell.exec("echo ${STR/l/r}").unwrap();
969        assert_eq!(result.stdout, "herlo\n");
970    }
971
972    #[test]
973    fn expand_substitution_all() {
974        let mut shell = shell();
975        shell.exec("STR=hello").unwrap();
976        let result = shell.exec("echo ${STR//l/r}").unwrap();
977        assert_eq!(result.stdout, "herro\n");
978    }
979
980    #[test]
981    fn expand_substring() {
982        let mut shell = shell();
983        shell.exec("STR=hello").unwrap();
984        let result = shell.exec("echo ${STR:1:3}").unwrap();
985        assert_eq!(result.stdout, "ell\n");
986    }
987
988    #[test]
989    fn expand_uppercase_first() {
990        let mut shell = shell();
991        shell.exec("STR=hello").unwrap();
992        let result = shell.exec("echo ${STR^}").unwrap();
993        assert_eq!(result.stdout, "Hello\n");
994    }
995
996    #[test]
997    fn expand_uppercase_all() {
998        let mut shell = shell();
999        shell.exec("STR=hello").unwrap();
1000        let result = shell.exec("echo ${STR^^}").unwrap();
1001        assert_eq!(result.stdout, "HELLO\n");
1002    }
1003
1004    #[test]
1005    fn expand_lowercase_first() {
1006        let mut shell = shell();
1007        shell.exec("STR=HELLO").unwrap();
1008        let result = shell.exec("echo ${STR,}").unwrap();
1009        assert_eq!(result.stdout, "hELLO\n");
1010    }
1011
1012    #[test]
1013    fn expand_lowercase_all() {
1014        let mut shell = shell();
1015        shell.exec("STR=HELLO").unwrap();
1016        let result = shell.exec("echo ${STR,,}").unwrap();
1017        assert_eq!(result.stdout, "hello\n");
1018    }
1019
1020    // ── Special variables ───────────────────────────────────────────
1021
1022    #[test]
1023    fn expand_exit_status() {
1024        let mut shell = shell();
1025        shell.exec("false").unwrap();
1026        let result = shell.exec("echo $?").unwrap();
1027        assert_eq!(result.stdout, "1\n");
1028    }
1029
1030    #[test]
1031    fn expand_dollar_dollar() {
1032        let mut shell = shell();
1033        let result = shell.exec("echo $$").unwrap();
1034        assert_eq!(result.stdout, "1\n");
1035    }
1036
1037    #[test]
1038    fn expand_dollar_zero() {
1039        let mut shell = shell();
1040        let result = shell.exec("echo $0").unwrap();
1041        assert_eq!(result.stdout, "rust-bash\n");
1042    }
1043
1044    #[test]
1045    fn expand_positional_params() {
1046        let mut shell = shell();
1047        shell.exec("set -- a b c").unwrap();
1048        let result = shell.exec("echo $1 $2 $3").unwrap();
1049        assert_eq!(result.stdout, "a b c\n");
1050    }
1051
1052    #[test]
1053    fn expand_param_count() {
1054        let mut shell = shell();
1055        shell.exec("set -- a b c").unwrap();
1056        let result = shell.exec("echo $#").unwrap();
1057        assert_eq!(result.stdout, "3\n");
1058    }
1059
1060    #[test]
1061    fn expand_at_all_params() {
1062        let mut shell = shell();
1063        shell.exec("set -- one two three").unwrap();
1064        let result = shell.exec("echo $@").unwrap();
1065        assert_eq!(result.stdout, "one two three\n");
1066    }
1067
1068    #[test]
1069    fn expand_star_all_params() {
1070        let mut shell = shell();
1071        shell.exec("set -- one two three").unwrap();
1072        let result = shell.exec("echo $*").unwrap();
1073        assert_eq!(result.stdout, "one two three\n");
1074    }
1075
1076    #[test]
1077    fn expand_random_is_numeric() {
1078        let mut shell = shell();
1079        let result = shell.exec("echo $RANDOM").unwrap();
1080        let val: u32 = result.stdout.trim().parse().unwrap();
1081        assert!(val <= 32767);
1082    }
1083
1084    // ── Tilde expansion ─────────────────────────────────────────────
1085
1086    #[test]
1087    fn tilde_expands_to_home() {
1088        let mut env = HashMap::new();
1089        env.insert("HOME".to_string(), "/home/test".to_string());
1090        let mut shell = RustBashBuilder::new().env(env).build().unwrap();
1091        let result = shell.exec("echo ~").unwrap();
1092        assert_eq!(result.stdout, "/home/test\n");
1093    }
1094
1095    // ── Redirections ────────────────────────────────────────────────
1096
1097    #[test]
1098    fn redirect_stdout_to_file() {
1099        let mut shell = shell();
1100        shell.exec("echo hello > /output.txt").unwrap();
1101        let content = shell.state.fs.read_file(Path::new("/output.txt")).unwrap();
1102        assert_eq!(String::from_utf8_lossy(&content), "hello\n");
1103    }
1104
1105    #[test]
1106    fn redirect_append() {
1107        let mut shell = shell();
1108        shell.exec("echo hello > /output.txt").unwrap();
1109        shell.exec("echo world >> /output.txt").unwrap();
1110        let content = shell.state.fs.read_file(Path::new("/output.txt")).unwrap();
1111        assert_eq!(String::from_utf8_lossy(&content), "hello\nworld\n");
1112    }
1113
1114    #[test]
1115    fn redirect_stdin_from_file() {
1116        let mut files = HashMap::new();
1117        files.insert("/input.txt".to_string(), b"file contents\n".to_vec());
1118        let mut shell = RustBashBuilder::new().files(files).build().unwrap();
1119        let result = shell.exec("cat < /input.txt").unwrap();
1120        assert_eq!(result.stdout, "file contents\n");
1121    }
1122
1123    #[test]
1124    fn redirect_stderr_to_file() {
1125        let mut shell = shell();
1126        shell.exec("nonexistent 2> /err.txt").unwrap();
1127        let content = shell.state.fs.read_file(Path::new("/err.txt")).unwrap();
1128        assert!(String::from_utf8_lossy(&content).contains("command not found"));
1129    }
1130
1131    #[test]
1132    fn redirect_dev_null() {
1133        let mut shell = shell();
1134        let result = shell.exec("echo hello > /dev/null").unwrap();
1135        assert_eq!(result.stdout, "");
1136    }
1137
1138    #[test]
1139    fn redirect_stderr_to_stdout() {
1140        let mut shell = shell();
1141        let result = shell.exec("nonexistent 2>&1").unwrap();
1142        assert!(result.stdout.contains("command not found"));
1143        assert_eq!(result.stderr, "");
1144    }
1145
1146    #[test]
1147    fn redirect_write_then_cat() {
1148        let mut shell = shell();
1149        shell.exec("echo hello > /test.txt").unwrap();
1150        let result = shell.exec("cat /test.txt").unwrap();
1151        assert_eq!(result.stdout, "hello\n");
1152    }
1153
1154    // ── cat command ─────────────────────────────────────────────────
1155
1156    #[test]
1157    fn cat_stdin() {
1158        let mut shell = shell();
1159        let result = shell.exec("echo hello | cat").unwrap();
1160        assert_eq!(result.stdout, "hello\n");
1161    }
1162
1163    #[test]
1164    fn cat_file() {
1165        let mut files = HashMap::new();
1166        files.insert("/test.txt".to_string(), b"content\n".to_vec());
1167        let mut shell = RustBashBuilder::new().files(files).build().unwrap();
1168        let result = shell.exec("cat /test.txt").unwrap();
1169        assert_eq!(result.stdout, "content\n");
1170    }
1171
1172    #[test]
1173    fn cat_nonexistent_file() {
1174        let mut shell = shell();
1175        let result = shell.exec("cat /no_such_file.txt").unwrap();
1176        assert_eq!(result.exit_code, 1);
1177        assert!(result.stderr.contains("No such file"));
1178    }
1179
1180    #[test]
1181    fn cat_line_numbers() {
1182        let mut files = HashMap::new();
1183        files.insert("/test.txt".to_string(), b"a\nb\nc\n".to_vec());
1184        let mut shell = RustBashBuilder::new().files(files).build().unwrap();
1185        let result = shell.exec("cat -n /test.txt").unwrap();
1186        assert!(result.stdout.contains("1\ta"));
1187        assert!(result.stdout.contains("2\tb"));
1188        assert!(result.stdout.contains("3\tc"));
1189    }
1190
1191    // ── Builtins ────────────────────────────────────────────────────
1192
1193    #[test]
1194    fn cd_changes_cwd() {
1195        let mut shell = RustBashBuilder::new().cwd("/home/user").build().unwrap();
1196        shell.exec("cd /").unwrap();
1197        assert_eq!(shell.state.cwd, "/");
1198    }
1199
1200    #[test]
1201    fn cd_home() {
1202        let mut env = HashMap::new();
1203        env.insert("HOME".to_string(), "/home/test".to_string());
1204        let mut shell = RustBashBuilder::new()
1205            .cwd("/home/test")
1206            .env(env)
1207            .build()
1208            .unwrap();
1209        shell.exec("cd /").unwrap();
1210        shell.exec("cd").unwrap();
1211        assert_eq!(shell.state.cwd, "/home/test");
1212    }
1213
1214    #[test]
1215    fn cd_sets_oldpwd() {
1216        let mut shell = RustBashBuilder::new().cwd("/home/user").build().unwrap();
1217        shell.exec("cd /").unwrap();
1218        assert_eq!(
1219            shell.state.env.get("OLDPWD").unwrap().value.as_scalar(),
1220            "/home/user"
1221        );
1222    }
1223
1224    #[test]
1225    fn export_creates_exported_var() {
1226        let mut shell = shell();
1227        shell.exec("export FOO=bar").unwrap();
1228        let var = shell.state.env.get("FOO").unwrap();
1229        assert_eq!(var.value.as_scalar(), "bar");
1230        assert!(var.exported());
1231    }
1232
1233    #[test]
1234    fn export_marks_existing_var() {
1235        let mut shell = shell();
1236        shell.exec("FOO=bar").unwrap();
1237        assert!(!shell.state.env.get("FOO").unwrap().exported());
1238        shell.exec("export FOO").unwrap();
1239        assert!(shell.state.env.get("FOO").unwrap().exported());
1240    }
1241
1242    #[test]
1243    fn unset_removes_var() {
1244        let mut shell = shell();
1245        shell.exec("FOO=bar").unwrap();
1246        shell.exec("unset FOO").unwrap();
1247        assert!(!shell.state.env.contains_key("FOO"));
1248    }
1249
1250    #[test]
1251    fn set_options() {
1252        let mut shell = shell();
1253        shell.exec("set -e").unwrap();
1254        assert!(shell.state.shell_opts.errexit);
1255        shell.exec("set +e").unwrap();
1256        assert!(!shell.state.shell_opts.errexit);
1257    }
1258
1259    #[test]
1260    fn set_positional_params() {
1261        let mut shell = shell();
1262        shell.exec("set -- x y z").unwrap();
1263        assert_eq!(shell.state.positional_params, vec!["x", "y", "z"]);
1264    }
1265
1266    #[test]
1267    fn shift_positional_params() {
1268        let mut shell = shell();
1269        shell.exec("set -- a b c d").unwrap();
1270        shell.exec("shift 2").unwrap();
1271        assert_eq!(shell.state.positional_params, vec!["c", "d"]);
1272    }
1273
1274    #[test]
1275    fn readonly_variable() {
1276        let mut shell = shell();
1277        shell.exec("readonly X=42").unwrap();
1278        let var = shell.state.env.get("X").unwrap();
1279        assert_eq!(var.value.as_scalar(), "42");
1280        assert!(var.readonly());
1281        // Bash: assigning to readonly prints error to stderr & sets exit code 1
1282        let result = shell.exec("X=new").unwrap();
1283        assert_eq!(result.exit_code, 1);
1284        assert!(result.stderr.contains("readonly"));
1285        // Value unchanged
1286        assert_eq!(shell.state.env.get("X").unwrap().value.as_scalar(), "42");
1287    }
1288
1289    #[test]
1290    fn declare_readonly() {
1291        let mut shell = shell();
1292        shell.exec("declare -r Y=99").unwrap();
1293        assert!(shell.state.env.get("Y").unwrap().readonly());
1294    }
1295
1296    #[test]
1297    fn read_from_stdin() {
1298        let mut shell = shell();
1299        shell.exec("echo 'hello world' > /tmp_input").unwrap();
1300        let result = shell.exec("read VAR < /tmp_input").unwrap();
1301        assert_eq!(result.exit_code, 0);
1302        assert_eq!(
1303            shell.state.env.get("VAR").unwrap().value.as_scalar(),
1304            "hello world"
1305        );
1306    }
1307
1308    #[test]
1309    fn read_multiple_vars() {
1310        let mut shell = shell();
1311        shell
1312            .exec("echo 'one two three four' > /tmp_input")
1313            .unwrap();
1314        shell.exec("read A B < /tmp_input").unwrap();
1315        assert_eq!(shell.state.env.get("A").unwrap().value.as_scalar(), "one");
1316        assert_eq!(
1317            shell.state.env.get("B").unwrap().value.as_scalar(),
1318            "two three four"
1319        );
1320    }
1321
1322    #[test]
1323    fn colon_builtin() {
1324        let mut shell = shell();
1325        let result = shell.exec(":").unwrap();
1326        assert_eq!(result.exit_code, 0);
1327    }
1328
1329    // ── Combined features ───────────────────────────────────────────
1330
1331    #[test]
1332    fn variable_in_redirect_target() {
1333        let mut shell = shell();
1334        shell.exec("FILE=/output.txt").unwrap();
1335        shell.exec("echo hello > $FILE").unwrap();
1336        let content = shell.state.fs.read_file(Path::new("/output.txt")).unwrap();
1337        assert_eq!(String::from_utf8_lossy(&content), "hello\n");
1338    }
1339
1340    #[test]
1341    fn pipeline_with_variable() {
1342        let mut shell = shell();
1343        shell.exec("MSG=world").unwrap();
1344        let result = shell.exec("echo hello $MSG | cat").unwrap();
1345        assert_eq!(result.stdout, "hello world\n");
1346    }
1347
1348    #[test]
1349    fn set_and_expand_positional() {
1350        let mut shell = shell();
1351        shell.exec("set -- foo bar baz").unwrap();
1352        let result = shell.exec("echo $1 $3").unwrap();
1353        assert_eq!(result.stdout, "foo baz\n");
1354    }
1355
1356    #[test]
1357    fn shift_and_expand() {
1358        let mut shell = shell();
1359        shell.exec("set -- a b c").unwrap();
1360        shell.exec("shift").unwrap();
1361        let result = shell.exec("echo $1 $#").unwrap();
1362        assert_eq!(result.stdout, "b 2\n");
1363    }
1364
1365    #[test]
1366    fn set_pipefail_option() {
1367        let mut shell = shell();
1368        shell.exec("set -o pipefail").unwrap();
1369        assert!(shell.state.shell_opts.pipefail);
1370    }
1371
1372    #[test]
1373    fn double_quoted_variable_expansion() {
1374        let mut sh = shell();
1375        sh.exec("FOO='hello world'").unwrap();
1376        let result = sh.exec("echo \"$FOO\"").unwrap();
1377        assert_eq!(result.stdout, "hello world\n");
1378    }
1379
1380    #[test]
1381    fn empty_variable_in_quotes() {
1382        let mut shell = shell();
1383        let result = shell.exec("echo \"$EMPTY\"").unwrap();
1384        assert_eq!(result.stdout, "\n");
1385    }
1386
1387    #[test]
1388    fn here_string() {
1389        let mut shell = shell();
1390        let result = shell.exec("cat <<< 'hello world'").unwrap();
1391        assert_eq!(result.stdout, "hello world\n");
1392    }
1393
1394    #[test]
1395    fn output_and_error_redirect() {
1396        let mut shell = shell();
1397        shell.exec("echo hello &> /both.txt").unwrap();
1398        let content = shell.state.fs.read_file(Path::new("/both.txt")).unwrap();
1399        assert_eq!(String::from_utf8_lossy(&content), "hello\n");
1400    }
1401
1402    // ── Phase 1C: Compound commands ─────────────────────────────
1403
1404    #[test]
1405    fn if_then_true() {
1406        let mut shell = shell();
1407        let result = shell
1408            .exec("if true; then echo yes; else echo no; fi")
1409            .unwrap();
1410        assert_eq!(result.stdout, "yes\n");
1411        assert_eq!(result.exit_code, 0);
1412    }
1413
1414    #[test]
1415    fn if_then_false() {
1416        let mut shell = shell();
1417        let result = shell
1418            .exec("if false; then echo yes; else echo no; fi")
1419            .unwrap();
1420        assert_eq!(result.stdout, "no\n");
1421    }
1422
1423    #[test]
1424    fn if_elif_else() {
1425        let mut shell = shell();
1426        let result = shell
1427            .exec("if false; then echo a; elif true; then echo b; else echo c; fi")
1428            .unwrap();
1429        assert_eq!(result.stdout, "b\n");
1430    }
1431
1432    #[test]
1433    fn if_elif_falls_through_to_else() {
1434        let mut shell = shell();
1435        let result = shell
1436            .exec("if false; then echo a; elif false; then echo b; else echo c; fi")
1437            .unwrap();
1438        assert_eq!(result.stdout, "c\n");
1439    }
1440
1441    #[test]
1442    fn if_no_else_unmatched() {
1443        let mut shell = shell();
1444        let result = shell.exec("if false; then echo yes; fi").unwrap();
1445        assert_eq!(result.stdout, "");
1446        assert_eq!(result.exit_code, 0);
1447    }
1448
1449    #[test]
1450    fn if_with_command_condition() {
1451        let mut shell = shell();
1452        shell.exec("X=hello").unwrap();
1453        let result = shell
1454            .exec("if echo checking > /dev/null; then echo passed; fi")
1455            .unwrap();
1456        assert_eq!(result.stdout, "passed\n");
1457    }
1458
1459    #[test]
1460    fn for_loop_basic() {
1461        let mut shell = shell();
1462        let result = shell.exec("for i in a b c; do echo $i; done").unwrap();
1463        assert_eq!(result.stdout, "a\nb\nc\n");
1464    }
1465
1466    #[test]
1467    fn for_loop_with_variable_expansion() {
1468        let mut shell = shell();
1469        // Word splitting of unquoted $VAR not yet implemented,
1470        // so use separate words in the for list
1471        let result = shell.exec("for i in x y z; do echo $i; done").unwrap();
1472        assert_eq!(result.stdout, "x\ny\nz\n");
1473    }
1474
1475    #[test]
1476    fn for_loop_variable_persists_after_loop() {
1477        let mut shell = shell();
1478        shell.exec("for i in a b c; do true; done").unwrap();
1479        let result = shell.exec("echo $i").unwrap();
1480        assert_eq!(result.stdout, "c\n");
1481    }
1482
1483    #[test]
1484    fn while_loop_basic() {
1485        let mut shell = shell();
1486        // while false → condition fails immediately, body never runs
1487        let result = shell
1488            .exec("while false; do echo should-not-appear; done")
1489            .unwrap();
1490        assert_eq!(result.stdout, "");
1491    }
1492
1493    #[test]
1494    fn while_loop_executes_body() {
1495        let mut shell = shell();
1496        // Test while with a command that succeeds then fails.
1497        // Since we don't have `[` builtin yet, just verify the body runs
1498        // when condition is true, then stops when it becomes false.
1499        let _result = shell.exec(
1500            r#"X=yes; while echo $X > /dev/null && [ "$X" = yes ]; do echo looped; X=no; done"#,
1501        );
1502    }
1503
1504    #[test]
1505    fn until_loop_basic() {
1506        let mut shell = shell();
1507        let result = shell
1508            .exec("until true; do echo should-not-run; done")
1509            .unwrap();
1510        assert_eq!(result.stdout, "");
1511    }
1512
1513    #[test]
1514    fn until_loop_runs_once_when_condition_false() {
1515        let mut shell = shell();
1516        // until true → don't execute body (condition immediately true)
1517        let result = shell.exec("until true; do echo nope; done").unwrap();
1518        assert_eq!(result.stdout, "");
1519    }
1520
1521    #[test]
1522    fn brace_group_basic() {
1523        let mut shell = shell();
1524        let result = shell.exec("{ echo hello; echo world; }").unwrap();
1525        assert_eq!(result.stdout, "hello\nworld\n");
1526    }
1527
1528    #[test]
1529    fn brace_group_shares_scope() {
1530        let mut shell = shell();
1531        shell.exec("X=before").unwrap();
1532        shell.exec("{ X=after; }").unwrap();
1533        let result = shell.exec("echo $X").unwrap();
1534        assert_eq!(result.stdout, "after\n");
1535    }
1536
1537    #[test]
1538    fn subshell_basic() {
1539        let mut shell = shell();
1540        let result = shell.exec("(echo hello)").unwrap();
1541        assert_eq!(result.stdout, "hello\n");
1542    }
1543
1544    #[test]
1545    fn subshell_isolates_variables() {
1546        let mut shell = shell();
1547        let result = shell.exec("X=outer; (X=inner; echo $X); echo $X").unwrap();
1548        assert_eq!(result.stdout, "inner\nouter\n");
1549    }
1550
1551    #[test]
1552    fn subshell_isolates_cwd() {
1553        let mut shell = shell();
1554        shell.exec("mkdir /tmp").unwrap();
1555        let result = shell.exec("(cd /tmp && pwd); pwd").unwrap();
1556        assert_eq!(result.stdout, "/tmp\n/\n");
1557    }
1558
1559    #[test]
1560    fn subshell_propagates_exit_code() {
1561        let mut shell = shell();
1562        let result = shell.exec("(false)").unwrap();
1563        assert_eq!(result.exit_code, 1);
1564    }
1565
1566    #[test]
1567    fn subshell_isolates_fs_writes() {
1568        let mut shell = shell();
1569        shell.exec("(echo data > /subshell_file.txt)").unwrap();
1570        // The file was written in the subshell's cloned fs, NOT the parent
1571        let exists = shell.state.fs.exists(Path::new("/subshell_file.txt"));
1572        assert!(!exists);
1573    }
1574
1575    #[test]
1576    fn nested_if_in_for() {
1577        let mut shell = shell();
1578        let result = shell
1579            .exec("for x in yes no yes; do if true; then echo $x; fi; done")
1580            .unwrap();
1581        assert_eq!(result.stdout, "yes\nno\nyes\n");
1582    }
1583
1584    #[test]
1585    fn compound_command_with_redirect() {
1586        let mut shell = shell();
1587        shell
1588            .exec("{ echo hello; echo world; } > /out.txt")
1589            .unwrap();
1590        let content = shell.state.fs.read_file(Path::new("/out.txt")).unwrap();
1591        assert_eq!(String::from_utf8_lossy(&content), "hello\nworld\n");
1592    }
1593
1594    #[test]
1595    fn for_loop_in_pipeline() {
1596        let mut shell = shell();
1597        let result = shell
1598            .exec("for i in a b c; do echo $i; done | cat")
1599            .unwrap();
1600        assert_eq!(result.stdout, "a\nb\nc\n");
1601    }
1602
1603    #[test]
1604    fn if_in_pipeline() {
1605        let mut shell = shell();
1606        let result = shell.exec("if true; then echo yes; fi | cat").unwrap();
1607        assert_eq!(result.stdout, "yes\n");
1608    }
1609
1610    // ── Phase 1C: New commands ──────────────────────────────────
1611
1612    #[test]
1613    fn touch_creates_file() {
1614        let mut shell = shell();
1615        shell.exec("touch /newfile.txt").unwrap();
1616        assert!(shell.state.fs.exists(Path::new("/newfile.txt")));
1617        let content = shell.state.fs.read_file(Path::new("/newfile.txt")).unwrap();
1618        assert!(content.is_empty());
1619    }
1620
1621    #[test]
1622    fn touch_existing_file_no_error() {
1623        let mut shell = shell();
1624        shell.exec("echo data > /existing.txt").unwrap();
1625        let result = shell.exec("touch /existing.txt").unwrap();
1626        assert_eq!(result.exit_code, 0);
1627        // Content should remain
1628        let content = shell
1629            .state
1630            .fs
1631            .read_file(Path::new("/existing.txt"))
1632            .unwrap();
1633        assert_eq!(String::from_utf8_lossy(&content), "data\n");
1634    }
1635
1636    #[test]
1637    fn touch_and_ls() {
1638        let mut shell = shell();
1639        shell.exec("touch /file.txt").unwrap();
1640        let result = shell.exec("ls /").unwrap();
1641        assert!(result.stdout.contains("file.txt"));
1642    }
1643
1644    #[test]
1645    fn mkdir_creates_directory() {
1646        let mut shell = shell();
1647        let result = shell.exec("mkdir /mydir").unwrap();
1648        assert_eq!(result.exit_code, 0);
1649        assert!(shell.state.fs.exists(Path::new("/mydir")));
1650    }
1651
1652    #[test]
1653    fn mkdir_p_creates_parents() {
1654        let mut shell = shell();
1655        let result = shell.exec("mkdir -p /a/b/c").unwrap();
1656        assert_eq!(result.exit_code, 0);
1657        assert!(shell.state.fs.exists(Path::new("/a/b/c")));
1658    }
1659
1660    #[test]
1661    fn mkdir_p_and_ls() {
1662        let mut shell = shell();
1663        shell.exec("mkdir -p /a/b/c").unwrap();
1664        let result = shell.exec("ls /a/b").unwrap();
1665        assert!(result.stdout.contains("c"));
1666    }
1667
1668    #[test]
1669    fn ls_root_empty() {
1670        let mut shell = shell();
1671        let result = shell.exec("ls /").unwrap();
1672        assert_eq!(result.exit_code, 0);
1673    }
1674
1675    #[test]
1676    fn ls_one_per_line() {
1677        let mut shell = shell();
1678        shell.exec("mkdir /test_dir").unwrap();
1679        shell.exec("touch /test_dir/aaa").unwrap();
1680        shell.exec("touch /test_dir/bbb").unwrap();
1681        let result = shell.exec("ls -1 /test_dir").unwrap();
1682        assert_eq!(result.stdout, "aaa\nbbb\n");
1683    }
1684
1685    #[test]
1686    fn ls_long_format() {
1687        let mut shell = shell();
1688        shell.exec("touch /myfile").unwrap();
1689        let result = shell.exec("ls -l /").unwrap();
1690        assert!(result.stdout.contains("myfile"));
1691        // Should have permission string
1692        assert!(result.stdout.contains("rw"));
1693    }
1694
1695    #[test]
1696    fn ls_nonexistent() {
1697        let mut shell = shell();
1698        let result = shell.exec("ls /no_such_dir").unwrap();
1699        assert_ne!(result.exit_code, 0);
1700        assert!(result.stderr.contains("cannot access"));
1701    }
1702
1703    #[test]
1704    fn pwd_command() {
1705        let mut shell = shell();
1706        let result = shell.exec("pwd").unwrap();
1707        assert_eq!(result.stdout, "/\n");
1708    }
1709
1710    #[test]
1711    fn pwd_after_cd() {
1712        let mut shell = shell();
1713        shell.exec("mkdir /mydir").unwrap();
1714        shell.exec("cd /mydir").unwrap();
1715        let result = shell.exec("pwd").unwrap();
1716        assert_eq!(result.stdout, "/mydir\n");
1717    }
1718
1719    #[test]
1720    fn case_basic() {
1721        let mut shell = shell();
1722        let result = shell
1723            .exec("case hello in hello) echo matched;; world) echo nope;; esac")
1724            .unwrap();
1725        assert_eq!(result.stdout, "matched\n");
1726    }
1727
1728    #[test]
1729    fn case_wildcard() {
1730        let mut shell = shell();
1731        let result = shell
1732            .exec("case foo in bar) echo bar;; *) echo default;; esac")
1733            .unwrap();
1734        assert_eq!(result.stdout, "default\n");
1735    }
1736
1737    #[test]
1738    fn case_no_match() {
1739        let mut shell = shell();
1740        let result = shell.exec("case xyz in abc) echo nope;; esac").unwrap();
1741        assert_eq!(result.stdout, "");
1742        assert_eq!(result.exit_code, 0);
1743    }
1744
1745    #[test]
1746    fn register_default_commands_includes_new() {
1747        let cmds = crate::commands::register_default_commands();
1748        assert!(cmds.contains_key("touch"));
1749        assert!(cmds.contains_key("mkdir"));
1750        assert!(cmds.contains_key("ls"));
1751        assert!(cmds.contains_key("pwd"));
1752    }
1753
1754    // ── is_input_complete ──────────────────────────────────────────
1755
1756    #[test]
1757    fn complete_simple_commands() {
1758        assert!(RustBash::is_input_complete("echo hello"));
1759        assert!(RustBash::is_input_complete(""));
1760        assert!(RustBash::is_input_complete("   "));
1761    }
1762
1763    #[test]
1764    fn incomplete_unterminated_quotes() {
1765        assert!(!RustBash::is_input_complete("echo \"hello"));
1766        assert!(!RustBash::is_input_complete("echo 'hello"));
1767    }
1768
1769    #[test]
1770    fn incomplete_open_block() {
1771        assert!(!RustBash::is_input_complete("if true; then"));
1772        assert!(!RustBash::is_input_complete("for i in 1 2; do"));
1773    }
1774
1775    #[test]
1776    fn incomplete_trailing_pipe() {
1777        assert!(!RustBash::is_input_complete("echo hello |"));
1778    }
1779
1780    // ── Public accessors ───────────────────────────────────────────
1781
1782    #[test]
1783    fn cwd_accessor() {
1784        let sh = shell();
1785        assert_eq!(sh.cwd(), "/");
1786    }
1787
1788    #[test]
1789    fn last_exit_code_accessor() {
1790        let mut sh = shell();
1791        sh.exec("false").unwrap();
1792        assert_eq!(sh.last_exit_code(), 1);
1793    }
1794
1795    #[test]
1796    fn command_names_accessor() {
1797        let sh = shell();
1798        let names = sh.command_names();
1799        assert!(names.contains(&"echo"));
1800        assert!(names.contains(&"cat"));
1801    }
1802
1803    #[test]
1804    fn builder_accepts_custom_fs() {
1805        let custom_fs = Arc::new(crate::vfs::InMemoryFs::new());
1806        custom_fs
1807            .write_file(std::path::Path::new("/pre-existing.txt"), b"hello")
1808            .unwrap();
1809
1810        let mut shell = RustBashBuilder::new().fs(custom_fs).build().unwrap();
1811
1812        let result = shell.exec("cat /pre-existing.txt").unwrap();
1813        assert_eq!(result.stdout.trim(), "hello");
1814    }
1815
1816    #[test]
1817    fn should_exit_accessor() {
1818        let mut sh = shell();
1819        assert!(!sh.should_exit());
1820        sh.exec("exit").unwrap();
1821        assert!(sh.should_exit());
1822    }
1823}