Skip to main content

jt_consoleutils/shell/
mod.rs

1//! The [`Shell`](crate::shell::Shell) trait and its standard implementations.
2//!
3//! # Overview
4//!
5//! - [`Shell`](crate::shell::Shell) — the core trait; implement it to control how processes are
6//!   spawned.
7//! - [`ProcessShell`](crate::shell::ProcessShell) — the production implementation; spawns real OS
8//!   processes.
9//! - [`DryRunShell`](crate::shell::DryRunShell) — logs what would be executed and returns fake
10//!   success; safe to use in dry-run workflows.
11//! - [`MockShell`](crate::shell::MockShell) — records calls and returns configurable results;
12//!   intended for unit tests.
13//! - [`ScriptedShell`](crate::shell::scripted::ScriptedShell) — drives the real spinner overlay
14//!   using pre-configured output scripts; intended for overlay integration tests.
15//!
16//! Use [`create`](crate::shell::create) to get a boxed `Shell` at runtime based on a `dry_run`
17//! flag.
18
19use std::{
20   io,
21   process::{Command, Stdio}
22};
23
24use crate::output::{Output, OutputMode};
25
26mod exec;
27mod overlay;
28pub mod scripted;
29
30pub use exec::{run_command, run_passthrough};
31
32// ---------------------------------------------------------------------------
33// ShellConfig
34// ---------------------------------------------------------------------------
35
36/// Configuration for shell execution behaviour.
37///
38/// Build one explicitly or use `ShellConfig::default()` to get sensible
39/// defaults (viewport height of 5 lines).
40#[derive(Debug, Clone)]
41pub struct ShellConfig {
42   /// Number of output lines visible in the animated overlay viewport.
43   /// Older lines scroll out of view once this limit is reached.
44   pub viewport_size: usize
45}
46
47impl Default for ShellConfig {
48   fn default() -> Self {
49      Self { viewport_size: 5 }
50   }
51}
52
53// ---------------------------------------------------------------------------
54// Error
55// ---------------------------------------------------------------------------
56
57/// Errors that can be returned by [`Shell`] methods.
58#[derive(Debug, thiserror::Error)]
59pub enum ShellError {
60   /// The OS refused to spawn the process (e.g. binary not found, permission denied).
61   #[error("failed to spawn '{0}': {1}")]
62   Spawn(String, io::Error),
63   /// The process was spawned but waiting on it failed.
64   #[error("failed to wait on '{0}': {1}")]
65   Wait(String, io::Error),
66   /// The process exited with a non-zero status code.
67   #[error("command failed: {0}")]
68   Failed(String)
69}
70
71// ---------------------------------------------------------------------------
72// CommandResult
73// ---------------------------------------------------------------------------
74
75/// The outcome of a completed shell command.
76#[derive(Debug)]
77pub struct CommandResult {
78   /// `true` when the process exited with status 0.
79   pub success: bool,
80   /// All stderr output collected from the process, joined with newlines.
81   pub stderr: String
82}
83
84// ---------------------------------------------------------------------------
85// Shell trait
86// ---------------------------------------------------------------------------
87
88/// Abstraction over shell execution, enabling unit tests to mock process spawning.
89pub trait Shell {
90   /// Run `program` with `args`, displaying progress under `label`.
91   ///
92   /// Output behavior is controlled by `mode`:
93   /// - **quiet**: output is collected silently.
94   /// - **verbose**: each line is echoed with a `> ` prefix.
95   /// - **default**: an animated spinner overlay is shown.
96   fn run_command(
97      &self,
98      label: &str,
99      program: &str,
100      args: &[&str],
101      output: &mut dyn Output,
102      mode: OutputMode
103   ) -> Result<CommandResult, ShellError>;
104
105   /// Run an arbitrary shell script string (passed to `bash -c` / `powershell -Command`).
106   fn shell_exec(&self, script: &str, output: &mut dyn Output, mode: OutputMode) -> Result<CommandResult, ShellError>;
107
108   /// Return `true` when `program` can be found on `PATH`.
109   fn command_exists(&self, program: &str) -> bool;
110
111   /// Run `program args` and return its captured stdout as a trimmed `String`.
112   fn command_output(&self, program: &str, args: &[&str]) -> Result<String, ShellError>;
113
114   /// Run a shell command, capturing stdout/stderr silently without display.
115   /// In dry-run mode (`DryRunShell`), logs the command and returns success without executing.
116   fn exec_capture(&self, cmd: &str, output: &mut dyn Output, mode: OutputMode) -> Result<CommandResult, ShellError>;
117
118   /// Run a shell command with inherited stdio (for interactive flows like `aws sso login`).
119   /// In dry-run mode (`DryRunShell`), logs the command and returns success without executing.
120   fn exec_interactive(&self, cmd: &str, output: &mut dyn Output, mode: OutputMode) -> Result<(), ShellError>;
121}
122
123/// Returns a `DryRunShell` when `dry_run` is true, otherwise a `ProcessShell`.
124/// Both shells are configured with `ShellConfig::default()`.
125/// Use `ProcessShell` or `DryRunShell` directly if you need custom config.
126pub fn create(dry_run: bool) -> Box<dyn Shell> {
127   let config = ShellConfig::default();
128   if dry_run { Box::new(DryRunShell { config }) } else { Box::new(ProcessShell { config }) }
129}
130
131// ---------------------------------------------------------------------------
132// ProcessShell
133// ---------------------------------------------------------------------------
134
135/// Production shell: delegates to the free functions in this module.
136#[derive(Default)]
137pub struct ProcessShell {
138   /// Shell execution configuration (e.g. overlay viewport height).
139   pub config: ShellConfig
140}
141
142impl Shell for ProcessShell {
143   fn run_command(
144      &self,
145      label: &str,
146      program: &str,
147      args: &[&str],
148      output: &mut dyn Output,
149      mode: OutputMode
150   ) -> Result<CommandResult, ShellError> {
151      exec::run_command(label, program, args, output, mode, self.config.viewport_size)
152   }
153
154   fn shell_exec(&self, script: &str, output: &mut dyn Output, mode: OutputMode) -> Result<CommandResult, ShellError> {
155      shell_exec(script, output, mode, self.config.viewport_size)
156   }
157
158   fn command_exists(&self, program: &str) -> bool {
159      command_exists(program)
160   }
161
162   fn command_output(&self, program: &str, args: &[&str]) -> Result<String, ShellError> {
163      command_output(program, args)
164   }
165
166   fn exec_capture(&self, cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<CommandResult, ShellError> {
167      #[cfg(unix)]
168      let (program, flag) = ("bash", "-c");
169      #[cfg(windows)]
170      let (program, flag) = ("powershell", "-Command");
171      exec::run_quiet(program, &[flag, cmd])
172   }
173
174   fn exec_interactive(&self, cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<(), ShellError> {
175      #[cfg(unix)]
176      let (program, flag) = ("bash", "-c");
177      #[cfg(windows)]
178      let (program, flag) = ("powershell", "-Command");
179
180      let status = Command::new(program)
181         .args([flag, cmd])
182         .stdin(Stdio::inherit())
183         .stdout(Stdio::inherit())
184         .stderr(Stdio::inherit())
185         .spawn()
186         .map_err(|e| ShellError::Spawn(program.to_string(), e))?
187         .wait()
188         .map_err(|e| ShellError::Wait(program.to_string(), e))?;
189
190      if status.success() {
191         Ok(())
192      } else {
193         Err(ShellError::Failed(format!("'{cmd}' exited with {}", status.code().unwrap_or(-1))))
194      }
195   }
196}
197
198// ---------------------------------------------------------------------------
199// DryRunShell
200// ---------------------------------------------------------------------------
201
202/// Dry-run shell: logs what would be executed and returns fake success.
203/// Probe methods (command_exists, command_output) delegate to real implementations
204/// because they are read-only and safe to call.
205#[derive(Default)]
206pub struct DryRunShell {
207   /// Shell execution configuration (e.g. overlay viewport height).
208   pub config: ShellConfig
209}
210
211impl Shell for DryRunShell {
212   fn run_command(
213      &self,
214      _label: &str,
215      program: &str,
216      args: &[&str],
217      output: &mut dyn Output,
218      _mode: OutputMode
219   ) -> Result<CommandResult, ShellError> {
220      output.dry_run_shell(&format_command(program, args));
221      Ok(CommandResult { success: true, stderr: String::new() })
222   }
223
224   fn shell_exec(&self, script: &str, output: &mut dyn Output, _mode: OutputMode) -> Result<CommandResult, ShellError> {
225      output.dry_run_shell(script);
226      Ok(CommandResult { success: true, stderr: String::new() })
227   }
228
229   fn command_exists(&self, program: &str) -> bool {
230      command_exists(program)
231   }
232
233   fn command_output(&self, program: &str, args: &[&str]) -> Result<String, ShellError> {
234      command_output(program, args)
235   }
236
237   fn exec_capture(&self, cmd: &str, output: &mut dyn Output, _mode: OutputMode) -> Result<CommandResult, ShellError> {
238      output.dry_run_shell(cmd);
239      Ok(CommandResult { success: true, stderr: String::new() })
240   }
241
242   fn exec_interactive(&self, cmd: &str, output: &mut dyn Output, _mode: OutputMode) -> Result<(), ShellError> {
243      output.dry_run_shell(cmd);
244      Ok(())
245   }
246}
247
248// ---------------------------------------------------------------------------
249// MockShell (test only)
250// ---------------------------------------------------------------------------
251
252/// Mock shell for unit tests: records calls and returns configurable results.
253///
254/// Intended for **testing use**. Not gated behind `#[cfg(test)]` so that downstream
255/// crates can use it in their own test suites; LTO eliminates it from production builds.
256pub struct MockShell {
257   /// Ordered log of every call made to this shell, formatted as `"program arg1 arg2"`.
258   pub calls: std::cell::RefCell<Vec<String>>,
259   /// Value returned by `run_command` / `shell_exec` / `exec_capture`. Defaults to `true`.
260   pub run_success: bool,
261   /// Value returned by `command_exists`. Defaults to `true`.
262   pub command_exists_result: bool,
263   /// Stdout value returned by `command_output` when `command_output_ok` is `true`.
264   pub command_output_value: String,
265   /// When false, `command_output` returns `Err` (e.g. to simulate a tool not installed).
266   pub command_output_ok: bool,
267   /// Queue of results for `exec_capture` calls; pops front on each call.
268   /// If empty, falls back to `CommandResult { success: run_success, stderr: "" }`.
269   pub exec_capture_results: std::cell::RefCell<std::collections::VecDeque<CommandResult>>
270}
271
272impl Default for MockShell {
273   fn default() -> Self {
274      Self::new()
275   }
276}
277
278impl MockShell {
279   /// Create a new `MockShell` with all success flags set to `true` and empty recorded calls.
280   pub fn new() -> Self {
281      Self {
282         calls: std::cell::RefCell::new(Vec::new()),
283         run_success: true,
284         command_exists_result: true,
285         command_output_value: String::new(),
286         command_output_ok: true,
287         exec_capture_results: std::cell::RefCell::new(std::collections::VecDeque::new())
288      }
289   }
290
291   /// Return a snapshot of all calls recorded so far.
292   pub fn calls(&self) -> Vec<String> {
293      self.calls.borrow().clone()
294   }
295}
296
297impl Shell for MockShell {
298   fn run_command(
299      &self,
300      _label: &str,
301      program: &str,
302      args: &[&str],
303      _output: &mut dyn Output,
304      _mode: OutputMode
305   ) -> Result<CommandResult, ShellError> {
306      self.calls.borrow_mut().push(format_command(program, args));
307      Ok(CommandResult { success: self.run_success, stderr: String::new() })
308   }
309
310   fn shell_exec(
311      &self,
312      script: &str,
313      _output: &mut dyn Output,
314      _mode: OutputMode
315   ) -> Result<CommandResult, ShellError> {
316      self.calls.borrow_mut().push(format!("shell_exec: {script}"));
317      Ok(CommandResult { success: self.run_success, stderr: String::new() })
318   }
319
320   fn command_exists(&self, _program: &str) -> bool {
321      self.command_exists_result
322   }
323
324   fn command_output(&self, program: &str, args: &[&str]) -> Result<String, ShellError> {
325      let call = format_command(program, args);
326      self.calls.borrow_mut().push(call.clone());
327      if !self.command_output_ok {
328         return Err(ShellError::Failed(format!("'{call}' failed (mocked)")));
329      }
330      Ok(self.command_output_value.clone())
331   }
332
333   fn exec_capture(&self, cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<CommandResult, ShellError> {
334      self.calls.borrow_mut().push(format!("exec_capture: {cmd}"));
335      let result = self
336         .exec_capture_results
337         .borrow_mut()
338         .pop_front()
339         .unwrap_or_else(|| CommandResult { success: self.run_success, stderr: String::new() });
340      Ok(result)
341   }
342
343   fn exec_interactive(&self, cmd: &str, _output: &mut dyn Output, _mode: OutputMode) -> Result<(), ShellError> {
344      self.calls.borrow_mut().push(format!("interactive: {cmd}"));
345      Ok(())
346   }
347}
348
349// ---------------------------------------------------------------------------
350// Free functions
351// ---------------------------------------------------------------------------
352
353// ---------------------------------------------------------------------------
354// Tests
355// ---------------------------------------------------------------------------
356
357#[cfg(test)]
358mod tests {
359   use rstest::rstest;
360
361   use super::{CommandResult, DryRunShell, MockShell, Shell, ShellConfig, ShellError, format_command};
362   use crate::output::{OutputMode, StringOutput};
363
364   // -----------------------------------------------------------------------
365   // Helpers
366   // -----------------------------------------------------------------------
367
368   fn default_mode() -> OutputMode {
369      OutputMode::default()
370   }
371
372   fn dry_run_mode() -> OutputMode {
373      OutputMode { dry_run: true, ..Default::default() }
374   }
375
376   // -----------------------------------------------------------------------
377   // ShellConfig
378   // -----------------------------------------------------------------------
379
380   #[test]
381   fn shell_config_default_viewport_size_is_five() {
382      assert_eq!(ShellConfig::default().viewport_size, 5);
383   }
384
385   #[test]
386   fn shell_config_clone_is_equal() {
387      let cfg = ShellConfig { viewport_size: 10 };
388      let cloned = cfg.clone();
389      assert_eq!(cloned.viewport_size, 10);
390   }
391
392   // -----------------------------------------------------------------------
393   // format_command (tested indirectly via MockShell call recording)
394   // -----------------------------------------------------------------------
395
396   #[rstest]
397   #[case("echo", &[], "echo")]
398   #[case("git", &["status"], "git status")]
399   #[case("cargo", &["build", "--release"], "cargo build --release")]
400   fn format_command_joins_program_and_args(#[case] program: &str, #[case] args: &[&str], #[case] expected: &str) {
401      assert_eq!(format_command(program, args), expected);
402   }
403
404   // -----------------------------------------------------------------------
405   // DryRunShell — run_command
406   // -----------------------------------------------------------------------
407
408   #[test]
409   fn dry_run_shell_run_command_emits_dry_run_line() {
410      let shell = DryRunShell::default();
411      let mut out = StringOutput::new();
412      let result = shell.run_command("build", "cargo", &["build"], &mut out, default_mode()).unwrap();
413      assert!(out.log().contains("[dry-run] would run: cargo build"));
414      assert!(result.success);
415      assert!(result.stderr.is_empty());
416   }
417
418   #[test]
419   fn dry_run_shell_run_command_no_args_emits_program_only() {
420      let shell = DryRunShell::default();
421      let mut out = StringOutput::new();
422      shell.run_command("check", "whoami", &[], &mut out, default_mode()).unwrap();
423      assert!(out.log().contains("[dry-run] would run: whoami"));
424   }
425
426   // -----------------------------------------------------------------------
427   // DryRunShell — shell_exec
428   // -----------------------------------------------------------------------
429
430   #[test]
431   fn dry_run_shell_shell_exec_emits_script() {
432      let shell = DryRunShell::default();
433      let mut out = StringOutput::new();
434      let result = shell.shell_exec("echo hello && echo world", &mut out, default_mode()).unwrap();
435      assert!(out.log().contains("[dry-run] would run: echo hello && echo world"));
436      assert!(result.success);
437      assert!(result.stderr.is_empty());
438   }
439
440   // -----------------------------------------------------------------------
441   // DryRunShell — exec_capture
442   // -----------------------------------------------------------------------
443
444   #[test]
445   fn dry_run_shell_exec_capture_emits_command() {
446      let shell = DryRunShell::default();
447      let mut out = StringOutput::new();
448      let result = shell.exec_capture("ls -la", &mut out, default_mode()).unwrap();
449      assert!(out.log().contains("[dry-run] would run: ls -la"));
450      assert!(result.success);
451      assert!(result.stderr.is_empty());
452   }
453
454   // -----------------------------------------------------------------------
455   // DryRunShell — exec_interactive
456   // -----------------------------------------------------------------------
457
458   #[test]
459   fn dry_run_shell_exec_interactive_emits_command() {
460      let shell = DryRunShell::default();
461      let mut out = StringOutput::new();
462      shell.exec_interactive("aws sso login", &mut out, default_mode()).unwrap();
463      assert!(out.log().contains("[dry-run] would run: aws sso login"));
464   }
465
466   // -----------------------------------------------------------------------
467   // DryRunShell — probe methods delegate to real OS
468   // -----------------------------------------------------------------------
469
470   #[test]
471   fn dry_run_shell_command_exists_delegates_to_real_os() {
472      let shell = DryRunShell::default();
473      // "sh" is universally available on Unix; this just checks it doesn't panic.
474      let _ = shell.command_exists("sh");
475   }
476
477   #[test]
478   fn dry_run_shell_command_output_delegates_to_real_os() {
479      let shell = DryRunShell::default();
480      // A benign read-only command available everywhere.
481      let result = shell.command_output("echo", &["hello"]);
482      assert!(result.is_ok());
483      assert_eq!(result.unwrap(), "hello");
484   }
485
486   // -----------------------------------------------------------------------
487   // DryRunShell — mode independence
488   // -----------------------------------------------------------------------
489
490   #[test]
491   fn dry_run_shell_run_command_emits_regardless_of_mode_flag() {
492      // DryRunShell ignores the OutputMode — it always dry-runs.
493      let shell = DryRunShell::default();
494      let mut out = StringOutput::new();
495      shell.run_command("x", "true", &[], &mut out, dry_run_mode()).unwrap();
496      assert!(out.log().contains("[dry-run] would run: true"));
497   }
498
499   // -----------------------------------------------------------------------
500   // MockShell — call recording
501   // -----------------------------------------------------------------------
502
503   #[test]
504   fn mock_shell_records_run_command_call() {
505      let shell = MockShell::new();
506      let mut out = StringOutput::new();
507      shell.run_command("label", "git", &["status"], &mut out, default_mode()).unwrap();
508      assert_eq!(shell.calls(), vec!["git status"]);
509   }
510
511   #[test]
512   fn mock_shell_records_multiple_calls_in_order() {
513      let shell = MockShell::new();
514      let mut out = StringOutput::new();
515      shell.run_command("a", "echo", &["one"], &mut out, default_mode()).unwrap();
516      shell.run_command("b", "echo", &["two"], &mut out, default_mode()).unwrap();
517      assert_eq!(shell.calls(), vec!["echo one", "echo two"]);
518   }
519
520   #[test]
521   fn mock_shell_records_shell_exec_call() {
522      let shell = MockShell::new();
523      let mut out = StringOutput::new();
524      shell.shell_exec("npm install", &mut out, default_mode()).unwrap();
525      assert_eq!(shell.calls(), vec!["shell_exec: npm install"]);
526   }
527
528   #[test]
529   fn mock_shell_records_command_output_call() {
530      let shell = MockShell::new();
531      let _ = shell.command_output("node", &["--version"]);
532      assert_eq!(shell.calls(), vec!["node --version"]);
533   }
534
535   #[test]
536   fn mock_shell_records_exec_capture_call() {
537      let shell = MockShell::new();
538      let mut out = StringOutput::new();
539      shell.exec_capture("date", &mut out, default_mode()).unwrap();
540      assert_eq!(shell.calls(), vec!["exec_capture: date"]);
541   }
542
543   #[test]
544   fn mock_shell_records_exec_interactive_call() {
545      let shell = MockShell::new();
546      let mut out = StringOutput::new();
547      shell.exec_interactive("aws sso login", &mut out, default_mode()).unwrap();
548      assert_eq!(shell.calls(), vec!["interactive: aws sso login"]);
549   }
550
551   // -----------------------------------------------------------------------
552   // MockShell — run_success flag
553   // -----------------------------------------------------------------------
554
555   #[test]
556   fn mock_shell_run_command_returns_success_by_default() {
557      let shell = MockShell::new();
558      let mut out = StringOutput::new();
559      let result = shell.run_command("x", "true", &[], &mut out, default_mode()).unwrap();
560      assert!(result.success);
561   }
562
563   #[test]
564   fn mock_shell_run_command_returns_failure_when_configured() {
565      let mut shell = MockShell::new();
566      shell.run_success = false;
567      let mut out = StringOutput::new();
568      let result = shell.run_command("x", "false", &[], &mut out, default_mode()).unwrap();
569      assert!(!result.success);
570   }
571
572   #[test]
573   fn mock_shell_shell_exec_honours_run_success() {
574      let mut shell = MockShell::new();
575      shell.run_success = false;
576      let mut out = StringOutput::new();
577      let result = shell.shell_exec("bad script", &mut out, default_mode()).unwrap();
578      assert!(!result.success);
579   }
580
581   // -----------------------------------------------------------------------
582   // MockShell — command_exists_result flag
583   // -----------------------------------------------------------------------
584
585   #[test]
586   fn mock_shell_command_exists_true_by_default() {
587      let shell = MockShell::new();
588      assert!(shell.command_exists("anything"));
589   }
590
591   #[test]
592   fn mock_shell_command_exists_false_when_configured() {
593      let mut shell = MockShell::new();
594      shell.command_exists_result = false;
595      assert!(!shell.command_exists("anything"));
596   }
597
598   // -----------------------------------------------------------------------
599   // MockShell — command_output value and ok flag
600   // -----------------------------------------------------------------------
601
602   #[test]
603   fn mock_shell_command_output_returns_configured_value() {
604      let mut shell = MockShell::new();
605      shell.command_output_value = "v18.0.0".to_string();
606      let result = shell.command_output("node", &["--version"]).unwrap();
607      assert_eq!(result, "v18.0.0");
608   }
609
610   #[test]
611   fn mock_shell_command_output_returns_err_when_ok_is_false() {
612      let mut shell = MockShell::new();
613      shell.command_output_ok = false;
614      let result = shell.command_output("node", &["--version"]);
615      assert!(matches!(result, Err(ShellError::Failed(_))));
616   }
617
618   #[test]
619   fn mock_shell_command_output_error_still_records_call() {
620      let mut shell = MockShell::new();
621      shell.command_output_ok = false;
622      let _ = shell.command_output("node", &["--version"]);
623      assert_eq!(shell.calls(), vec!["node --version"]);
624   }
625
626   // -----------------------------------------------------------------------
627   // MockShell — exec_capture_results queue
628   // -----------------------------------------------------------------------
629
630   #[test]
631   fn mock_shell_exec_capture_pops_from_queue() {
632      let shell = MockShell::new();
633      shell
634         .exec_capture_results
635         .borrow_mut()
636         .push_back(CommandResult { success: false, stderr: "queue error".to_string() });
637      let mut out = StringOutput::new();
638      let result = shell.exec_capture("any cmd", &mut out, default_mode()).unwrap();
639      assert!(!result.success);
640      assert_eq!(result.stderr, "queue error");
641   }
642
643   #[test]
644   fn mock_shell_exec_capture_falls_back_to_run_success_when_queue_empty() {
645      let mut shell = MockShell::new();
646      shell.run_success = false;
647      let mut out = StringOutput::new();
648      let result = shell.exec_capture("any cmd", &mut out, default_mode()).unwrap();
649      assert!(!result.success);
650   }
651
652   #[test]
653   fn mock_shell_exec_capture_queue_consumed_in_order() {
654      let shell = MockShell::new();
655      shell.exec_capture_results.borrow_mut().push_back(CommandResult { success: true, stderr: String::new() });
656      shell.exec_capture_results.borrow_mut().push_back(CommandResult { success: false, stderr: "second".to_string() });
657      let mut out = StringOutput::new();
658      let r1 = shell.exec_capture("cmd", &mut out, default_mode()).unwrap();
659      let r2 = shell.exec_capture("cmd", &mut out, default_mode()).unwrap();
660      assert!(r1.success);
661      assert!(!r2.success);
662      assert_eq!(r2.stderr, "second");
663   }
664
665   // -----------------------------------------------------------------------
666   // MockShell — exec_interactive
667   // -----------------------------------------------------------------------
668
669   #[test]
670   fn mock_shell_exec_interactive_returns_ok() {
671      let shell = MockShell::new();
672      let mut out = StringOutput::new();
673      assert!(shell.exec_interactive("interactive cmd", &mut out, default_mode()).is_ok());
674   }
675
676   // -----------------------------------------------------------------------
677   // MockShell — mixed call sequence
678   // -----------------------------------------------------------------------
679
680   #[test]
681   fn mock_shell_mixed_calls_all_recorded() {
682      let shell = MockShell::new();
683      let mut out = StringOutput::new();
684      shell.run_command("a", "git", &["fetch"], &mut out, default_mode()).unwrap();
685      shell.shell_exec("make build", &mut out, default_mode()).unwrap();
686      shell.exec_capture("docker ps", &mut out, default_mode()).unwrap();
687      shell.exec_interactive("ssh host", &mut out, default_mode()).unwrap();
688      let calls = shell.calls();
689      assert_eq!(calls[0], "git fetch");
690      assert_eq!(calls[1], "shell_exec: make build");
691      assert_eq!(calls[2], "exec_capture: docker ps");
692      assert_eq!(calls[3], "interactive: ssh host");
693   }
694}
695
696fn format_command(program: &str, args: &[&str]) -> String {
697   std::iter::once(program).chain(args.iter().copied()).collect::<Vec<_>>().join(" ")
698}
699
700/// Check if a program is on PATH.
701/// Uses `which` on unix, `where.exe` on windows.
702pub fn command_exists(program: &str) -> bool {
703   #[cfg(unix)]
704   let check = Command::new("which").arg(program).output();
705   #[cfg(windows)]
706   let check = Command::new("where.exe").arg(program).output();
707
708   check.map(|o| o.status.success()).unwrap_or(false)
709}
710
711/// Run a command and return its stdout (trimmed).
712pub fn command_output(program: &str, args: &[&str]) -> Result<String, ShellError> {
713   let output = Command::new(program)
714      .args(args)
715      .stdout(Stdio::piped())
716      .stderr(Stdio::piped())
717      .output()
718      .map_err(|e| ShellError::Spawn(program.to_string(), e))?;
719
720   if !output.status.success() {
721      let stderr = String::from_utf8_lossy(&output.stderr);
722      return Err(ShellError::Failed(format!(
723         "'{program}' exited with {}: {}",
724         output.status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".to_string()),
725         stderr.trim(),
726      )));
727   }
728
729   Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
730}
731
732/// Execute a script via the system shell.
733/// Unix: `bash -c "script"`, Windows: `powershell -Command "script"`.
734pub fn shell_exec(
735   script: &str,
736   output: &mut dyn Output,
737   mode: OutputMode,
738   viewport_size: usize
739) -> Result<CommandResult, ShellError> {
740   #[cfg(unix)]
741   let (program, shell_args) = ("bash", vec!["-c", script]);
742   #[cfg(windows)]
743   let (program, shell_args) = ("powershell", vec!["-Command", script]);
744
745   exec::run_command(&format!("Running: {script}"), program, &shell_args, output, mode, viewport_size)
746}