//! 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; /// Run without the probe timeout: used for legitimately long /// operations (package installs, git clones, ...). The default /// implementation delegates to run(). fn run_untimed( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, ) -> Result { self.run(cmd, args, env, cwd, capture) } /// 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> SystemRunner<'a> { /// Shared implementation. The timeout applies only to captured /// probes; long-running installs pass None via run_untimed(). fn run_inner( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, timeout: Option, ) -> 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 { c.stdout(std::process::Stdio::piped()); c.stderr(std::process::Stdio::piped()); let mut child = c .spawn() .with_context(|| format!("failed to execute: {display}"))?; let stdout_pipe = child.stdout.take(); let stderr_pipe = child.stderr.take(); let stdout_handle = std::thread::spawn(move || { let mut out = Vec::new(); if let Some(mut pipe) = stdout_pipe { let _ = std::io::Read::read_to_end(&mut pipe, &mut out); } out }); let stderr_handle = std::thread::spawn(move || { let mut err = Vec::new(); if let Some(mut pipe) = stderr_pipe { let _ = std::io::Read::read_to_end(&mut pipe, &mut err); } err }); let status = if let Some(timeout) = timeout { use wait_timeout::ChildExt; match child.wait_timeout(timeout) { Ok(Some(s)) => s, Ok(None) => { let _ = child.kill(); let _ = child.wait(); self.log .warn(format!("command timed out after {timeout:?}: {display}")); let stdout = String::from_utf8_lossy(&stdout_handle.join().unwrap_or_default()) .to_string(); let stderr = String::from_utf8_lossy(&stderr_handle.join().unwrap_or_default()) .to_string(); return Ok(RunOutput { code: -1, stdout, stderr, }); } Err(_) => { let _ = child.kill(); let _ = child.wait(); return Ok(RunOutput { code: -1, stdout: String::new(), stderr: String::new(), }); } } } else { child .wait() .with_context(|| format!("failed to wait: {display}"))? }; let stdout_bytes = stdout_handle.join().unwrap_or_default(); let stderr_bytes = stderr_handle.join().unwrap_or_default(); let code = status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&stdout_bytes).to_string(); let stderr = String::from_utf8_lossy(&stderr_bytes).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(), }) } } } impl<'a> Runner for SystemRunner<'a> { /// Probes are bounded by a 30 s timeout so a misbehaving process can /// never hang doctor/version detection. fn run( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, ) -> Result { self.run_inner( cmd, args, env, cwd, capture, Some(std::time::Duration::from_secs(30)), ) } /// Installs and other legitimately long operations run without the /// probe timeout (npm/cargo/go installs can take minutes). fn run_untimed( &self, cmd: &str, args: &[String], env: &BTreeMap, cwd: Option<&Path>, capture: bool, ) -> Result { self.run_inner(cmd, args, env, cwd, capture, None) } 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 } } }