1use 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#[derive(Debug, Clone)]
41pub struct ShellConfig {
42 pub viewport_size: usize
45}
46
47impl Default for ShellConfig {
48 fn default() -> Self {
49 Self { viewport_size: 5 }
50 }
51}
52
53#[derive(Debug, thiserror::Error)]
59pub enum ShellError {
60 #[error("failed to spawn '{0}': {1}")]
62 Spawn(String, io::Error),
63 #[error("failed to wait on '{0}': {1}")]
65 Wait(String, io::Error),
66 #[error("command failed: {0}")]
68 Failed(String)
69}
70
71#[derive(Debug)]
77pub struct CommandResult {
78 pub success: bool,
80 pub stderr: String
82}
83
84pub trait Shell {
90 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 fn shell_exec(&self, script: &str, output: &mut dyn Output, mode: OutputMode) -> Result<CommandResult, ShellError>;
107
108 fn command_exists(&self, program: &str) -> bool;
110
111 fn command_output(&self, program: &str, args: &[&str]) -> Result<String, ShellError>;
113
114 fn exec_capture(&self, cmd: &str, output: &mut dyn Output, mode: OutputMode) -> Result<CommandResult, ShellError>;
117
118 fn exec_interactive(&self, cmd: &str, output: &mut dyn Output, mode: OutputMode) -> Result<(), ShellError>;
121}
122
123pub 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#[derive(Default)]
137pub struct ProcessShell {
138 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#[derive(Default)]
206pub struct DryRunShell {
207 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
248pub struct MockShell {
257 pub calls: std::cell::RefCell<Vec<String>>,
259 pub run_success: bool,
261 pub command_exists_result: bool,
263 pub command_output_value: String,
265 pub command_output_ok: bool,
267 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 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 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#[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
471 fn dry_run_shell_command_exists_delegates_to_real_os() {
472 let shell = DryRunShell::default();
473 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 let result = shell.command_output("echo", &["hello"]);
482 assert!(result.is_ok());
483 assert_eq!(result.unwrap(), "hello");
484 }
485
486 #[test]
491 fn dry_run_shell_run_command_emits_regardless_of_mode_flag() {
492 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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
700pub 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
711pub 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
732pub 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}