//! Command execution abstraction: real process execution for production, //! an in-memory recorder for tests and dry runs. use crate::output::Logger; use anyhow::{Context, Result}; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Default)] pub struct RunOutput { pub code: i32, pub stdout: String, pub stderr: String, } /// Resolve a command name to an executable path, handling Windows /// .cmd/.bat scripts (which std::process::Command cannot launch directly). /// Returns the program to spawn and a prefix of arguments to insert before /// the caller's arguments (e.g. cmd.exe /C script.cmd). pub fn resolve_program(cmd: &str) -> (String, Vec) { #[cfg(windows)] { let path = if cmd.contains('\\') || cmd.contains('/') { Some(PathBuf::from(cmd)) } else { which::which(cmd).ok() }; if let Some(p) = path { let ext = p .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); if ext == "cmd" || ext == "bat" { let comspec = std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()); return (comspec, vec!["/C".to_string(), p.display().to_string()]); } return (p.display().to_string(), Vec::new()); } } (cmd.to_string(), Vec::new()) } /// Execute external commands with logging and dry-run support. pub trait Runner { fn run( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, ) -> Result; /// Locate a binary on the PATH (like the which command). fn which(&self, bin: &str) -> Option; } /// Production runner backed by std::process::Command. pub struct SystemRunner<'a> { pub dry_run: bool, pub verbose: bool, pub log: &'a Logger, } impl<'a> SystemRunner<'a> { pub fn new(dry_run: bool, verbose: bool, log: &'a Logger) -> Self { Self { dry_run, verbose, log, } } } impl<'a> Runner for SystemRunner<'a> { fn run( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, ) -> Result { let display = if args.is_empty() { cmd.to_string() } else { format!("{cmd} {}", args.join(" ")) }; if self.dry_run { self.log.dry(format!("would run: {display}")); return Ok(RunOutput::default()); } self.log.cmd(&display); let (prog, prefix) = resolve_program(cmd); let mut full_args = prefix; full_args.extend(args.iter().cloned()); let mut c = std::process::Command::new(&prog); c.args(&full_args); c.envs(env); if let Some(dir) = cwd { c.current_dir(dir); } if capture { let out = c .output() .with_context(|| format!("failed to execute: {display}"))?; let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).to_string(); let stderr = String::from_utf8_lossy(&out.stderr).to_string(); self.log.result(code, &stderr); Ok(RunOutput { code, stdout, stderr, }) } else { let status = c .status() .with_context(|| format!("failed to execute: {display}"))?; Ok(RunOutput { code: status.code().unwrap_or(-1), stdout: String::new(), stderr: String::new(), }) } } fn which(&self, bin: &str) -> Option { which::which(bin).ok() } } /// In-memory runner for tests: records every invocation, returns success. #[derive(Default)] pub struct MockRunner { pub calls: std::sync::Mutex>, pub found: HashSet, pub outputs: std::sync::Mutex>, } impl MockRunner { pub fn new() -> Self { Self::default() } pub fn with_found(mut self, bin: &str) -> Self { self.found.insert(bin.to_string()); self } pub fn with_output(mut self, out: RunOutput) -> Self { self.outputs.get_mut().unwrap().push(out); self } pub fn snapshot(&self) -> Vec { self.calls.lock().unwrap().clone() } } impl Runner for MockRunner { fn run( &self, cmd: &str, args: &[String], _env: &BTreeMap, _cwd: Option<&Path>, _capture: bool, ) -> Result { self.calls .lock() .unwrap() .push(format!("{cmd} {}", args.join(" "))); let out = self.outputs.lock().unwrap().pop(); Ok(out.unwrap_or_default()) } fn which(&self, bin: &str) -> Option { if self.found.contains(bin) { Some(PathBuf::from(bin)) } else { None } } }