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