use anyhow::Result;
use std::io::Read;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(30);
pub trait CommandRunner: Send + Sync {
fn run(&self, program: &str, args: &[&str]) -> Result<Output>;
fn exists(&self, program: &str) -> bool;
}
pub struct SystemCommandRunner;
impl CommandRunner for SystemCommandRunner {
fn run(&self, program: &str, args: &[&str]) -> Result<Output> {
let mut child = Command::new(program)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| anyhow::anyhow!("failed to spawn `{}`: {}", program, e))?;
match child.wait_timeout(COMMAND_TIMEOUT)? {
Some(status) => {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
if let Some(ref mut out) = child.stdout {
out.read_to_end(&mut stdout)?;
}
if let Some(ref mut err) = child.stderr {
err.read_to_end(&mut stderr)?;
}
Ok(Output {
status,
stdout,
stderr,
})
}
None => {
let _ = child.kill();
let _ = child.wait();
anyhow::bail!(
"command `{}` did not complete within {}s and was killed",
program,
COMMAND_TIMEOUT.as_secs()
);
}
}
}
fn exists(&self, program: &str) -> bool {
Command::new("which")
.arg(program)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_tool_not_found_returns_error() {
let runner = SystemCommandRunner;
let result = runner.run("this-tool-does-not-exist-12345", &[]);
assert!(result.is_err(), "non-existent tool must error");
}
#[test]
fn run_successful_command_returns_output() {
let runner = SystemCommandRunner;
let result = runner.run("echo", &["hello"]).unwrap();
assert!(result.status.success());
assert_eq!(result.stdout.as_slice(), b"hello\n");
}
#[test]
fn run_captures_stderr() {
let runner = SystemCommandRunner;
let result = runner.run("sh", &["-c", "echo errmsg >&2"]).unwrap();
assert!(result.status.success());
assert_eq!(result.stderr.as_slice(), b"errmsg\n");
}
#[test]
fn run_long_command_respects_timeout() {
assert!(
COMMAND_TIMEOUT <= Duration::from_secs(60),
"timeout should be at most 60s, got {}s",
COMMAND_TIMEOUT.as_secs()
);
assert!(
COMMAND_TIMEOUT >= Duration::from_secs(1),
"timeout should be at least 1s, got {}s",
COMMAND_TIMEOUT.as_secs()
);
}
}