use std::process::Output;
pub trait CommandRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error>;
}
pub struct RealCommandRunner;
impl CommandRunner for RealCommandRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error> {
std::process::Command::new(program).args(args).output()
}
}
pub struct MockCommandRunner {
responses: Vec<(String, Vec<String>, Result<Output, std::io::Error>)>,
calls: std::sync::Mutex<Vec<(String, Vec<String>)>>,
}
impl MockCommandRunner {
pub fn new() -> Self {
Self {
responses: Vec::new(),
calls: std::sync::Mutex::new(Vec::new()),
}
}
pub fn add_response(
mut self,
program: &str,
args: Vec<String>,
result: Result<Output, std::io::Error>,
) -> Self {
self.responses.push((program.to_string(), args, result));
self
}
pub fn add_always(self, program: &str, result: Result<Output, std::io::Error>) -> Self {
self.add_response(program, Vec::new(), result)
}
pub fn calls(&self) -> Vec<(String, Vec<String>)> {
self.calls.lock().unwrap_or_else(|e| e.into_inner()).clone()
}
}
impl Default for MockCommandRunner {
fn default() -> Self {
Self::new()
}
}
impl CommandRunner for MockCommandRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<Output, std::io::Error> {
let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
self.calls
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((program.to_string(), args_vec.clone()));
for (resp_prog, resp_args, resp_result) in &self.responses {
if resp_prog == program && (resp_args.is_empty() || resp_args == &args_vec) {
return match resp_result {
Ok(output) => Ok(Output {
status: output.status,
stdout: output.stdout.clone(),
stderr: output.stderr.clone(),
}),
Err(e) => Err(std::io::Error::new(e.kind(), e.to_string())),
};
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("no mock response for {program}"),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn success_exit_status() -> std::process::ExitStatus {
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(0)
}
#[cfg(not(unix))]
{
std::process::ExitStatus::default()
}
}
#[test]
fn mock_runner_returns_predefined_response() {
let runner = MockCommandRunner::new().add_always(
"echo",
Ok(Output {
status: success_exit_status(),
stdout: b"hello\n".to_vec(),
stderr: Vec::new(),
}),
);
let output = runner.run("echo", &["test"]).unwrap();
assert_eq!(output.stdout, b"hello\n");
}
#[test]
fn mock_runner_records_calls() {
let runner = MockCommandRunner::new().add_always(
"ls",
Ok(Output {
status: success_exit_status(),
stdout: Vec::new(),
stderr: Vec::new(),
}),
);
let _ = runner.run("ls", &["-la"]);
let _ = runner.run("ls", &["/tmp"]);
let calls = runner.calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].0, "ls");
assert_eq!(calls[1].1, vec!["/tmp".to_string()]);
}
#[test]
fn mock_runner_error_for_unknown_command() {
let runner = MockCommandRunner::new();
let result = runner.run("nonexistent", &[]);
assert!(result.is_err());
}
#[test]
fn real_runner_executes_command() {
let runner = RealCommandRunner;
let output = runner.run("echo", &["test"]).unwrap();
assert!(output.status.success());
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "test");
}
}