1use 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
16pub struct RustBash {
18 pub(crate) state: InterpreterState,
19}
20
21impl RustBash {
22 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 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 pub fn cwd(&self) -> &str {
55 &self.state.cwd
56 }
57
58 pub fn last_exit_code(&self) -> i32 {
60 self.state.last_exit_code
61 }
62
63 pub fn should_exit(&self) -> bool {
65 self.state.should_exit
66 }
67
68 pub fn command_names(&self) -> Vec<&str> {
70 self.state.commands.keys().map(|k| k.as_str()).collect()
71 }
72
73 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 pub fn set_shell_name(&mut self, name: String) {
80 self.state.shell_name = name;
81 }
82
83 pub fn set_positional_params(&mut self, params: Vec<String>) {
85 self.state.positional_params = params;
86 }
87
88 pub fn fs(&self) -> &Arc<dyn crate::vfs::VirtualFs> {
92 &self.state.fs
93 }
94
95 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 pub fn read_file(&self, path: &str) -> Result<Vec<u8>, crate::VfsError> {
108 self.state.fs.read_file(Path::new(path))
109 }
110
111 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 pub fn exists(&self, path: &str) -> bool {
123 self.state.fs.exists(Path::new(path))
124 }
125
126 pub fn readdir(&self, path: &str) -> Result<Vec<crate::vfs::DirEntry>, crate::VfsError> {
128 self.state.fs.readdir(Path::new(path))
129 }
130
131 pub fn stat(&self, path: &str) -> Result<crate::vfs::Metadata, crate::VfsError> {
133 self.state.fs.stat(Path::new(path))
134 }
135
136 pub fn remove_file(&self, path: &str) -> Result<(), crate::VfsError> {
138 self.state.fs.remove_file(Path::new(path))
139 }
140
141 pub fn remove_dir_all(&self, path: &str) -> Result<(), crate::VfsError> {
143 self.state.fs.remove_dir_all(Path::new(path))
144 }
145
146 pub fn register_command(&mut self, cmd: Arc<dyn VirtualCommand>) {
148 self.state.commands.insert(cmd.name().to_string(), cmd);
149 }
150
151 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 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 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, 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, }
234 }
235 }
236 }
237}
238
239pub 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 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 pub fn files(mut self, files: HashMap<String, Vec<u8>>) -> Self {
272 self.files = files;
273 self
274 }
275
276 pub fn env(mut self, env: HashMap<String, String>) -> Self {
278 self.env = env;
279 self
280 }
281
282 pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
284 self.cwd = Some(cwd.into());
285 self
286 }
287
288 pub fn command(mut self, cmd: Arc<dyn VirtualCommand>) -> Self {
290 self.custom_commands.push(cmd);
291 self
292 }
293
294 pub fn execution_limits(mut self, limits: ExecutionLimits) -> Self {
296 self.limits = Some(limits);
297 self
298 }
299
300 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 pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {
310 self.network_policy = Some(policy);
311 self
312 }
313
314 pub fn fs(mut self, fs: Arc<dyn VirtualFs>) -> Self {
320 self.fs = Some(fs);
321 self
322 }
323
324 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 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 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 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 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
462fn setup_default_filesystem(
467 fs: &dyn VirtualFs,
468 env: &HashMap<String, String>,
469 commands: &HashMap<String, Arc<dyn commands::VirtualCommand>>,
470) -> Result<(), RustBashError> {
471 for dir in &["/bin", "/usr/bin", "/tmp", "/dev"] {
473 let _ = fs.mkdir_p(Path::new(dir));
474 }
475
476 if let Some(home) = env.get("HOME") {
478 let _ = fs.mkdir_p(Path::new(home));
479 }
480
481 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let result = shell.exec("X=new").unwrap();
1288 assert_eq!(result.exit_code, 1);
1289 assert!(result.stderr.contains("readonly"));
1290 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 #[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 #[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 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 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 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 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 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 #[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 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 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 #[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 #[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}