use std::borrow::Cow;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Output, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use backon::{BlockingRetryable, ExponentialBuilder};
use wait_timeout::ChildExt;
use crate::error::RunError;
#[derive(Debug, Clone)]
pub struct RunOutput {
pub stdout: Vec<u8>,
pub stderr: String,
}
impl RunOutput {
pub fn stdout_lossy(&self) -> Cow<'_, str> {
String::from_utf8_lossy(&self.stdout)
}
}
pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<(), RunError> {
let status = Command::new(program).args(args).status().map_err(|source| {
RunError::Spawn {
program: program.to_string(),
source,
}
})?;
if status.success() {
Ok(())
} else {
Err(RunError::NonZeroExit {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
status,
stdout: Vec::new(),
stderr: String::new(),
})
}
}
pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
let output = Command::new(program).args(args).output().map_err(|source| {
RunError::Spawn {
program: program.to_string(),
source,
}
})?;
check_output(program, args, output)
}
pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
run_cmd_in_with_env(dir, program, args, &[])
}
pub fn run_cmd_in_with_env(
dir: &Path,
program: &str,
args: &[&str],
env: &[(&str, &str)],
) -> Result<RunOutput, RunError> {
let mut cmd = Command::new(program);
cmd.args(args).current_dir(dir);
for &(key, val) in env {
cmd.env(key, val);
}
let output = cmd.output().map_err(|source| RunError::Spawn {
program: program.to_string(),
source,
})?;
check_output(program, args, output)
}
pub fn run_cmd_in_with_timeout(
dir: &Path,
program: &str,
args: &[&str],
timeout: Duration,
) -> Result<RunOutput, RunError> {
let mut child = Command::new(program)
.args(args)
.current_dir(dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|source| RunError::Spawn {
program: program.to_string(),
source,
})?;
let stdout = child.stdout.take().expect("stdout piped");
let stderr = child.stderr.take().expect("stderr piped");
let stdout_handle = thread::spawn(move || read_to_end(stdout));
let stderr_handle = thread::spawn(move || read_to_end(stderr));
let start = Instant::now();
let wait_result = child.wait_timeout(timeout);
let outcome = match wait_result {
Ok(Some(status)) => Outcome::Exited(status),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
Outcome::TimedOut(start.elapsed())
}
Err(source) => {
let _ = child.kill();
let _ = child.wait();
Outcome::WaitFailed(source)
}
};
let stdout_bytes = stdout_handle.join().unwrap_or_default();
let stderr_bytes = stderr_handle.join().unwrap_or_default();
let stderr_str = String::from_utf8_lossy(&stderr_bytes).into_owned();
match outcome {
Outcome::Exited(status) => {
if status.success() {
Ok(RunOutput {
stdout: stdout_bytes,
stderr: stderr_str,
})
} else {
Err(RunError::NonZeroExit {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
status,
stdout: stdout_bytes,
stderr: stderr_str,
})
}
}
Outcome::TimedOut(elapsed) => Err(RunError::Timeout {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
elapsed,
stdout: stdout_bytes,
stderr: stderr_str,
}),
Outcome::WaitFailed(source) => Err(RunError::Spawn {
program: program.to_string(),
source,
}),
}
}
enum Outcome {
Exited(std::process::ExitStatus),
TimedOut(Duration),
WaitFailed(std::io::Error),
}
pub fn run_with_retry(
repo_path: &Path,
program: &str,
args: &[&str],
is_transient: impl Fn(&RunError) -> bool,
) -> Result<RunOutput, RunError> {
let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
let op = || {
let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
run_cmd_in(repo_path, program, &str_args)
};
op.retry(
ExponentialBuilder::default()
.with_factor(2.0)
.with_min_delay(Duration::from_millis(100))
.with_max_times(3),
)
.when(is_transient)
.call()
}
pub fn binary_available(name: &str) -> bool {
Command::new(name)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|s| s.success())
}
pub fn binary_version(name: &str) -> Option<String> {
let output = Command::new(name).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
if output.status.success() {
Ok(RunOutput {
stdout: output.stdout,
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
} else {
Err(RunError::NonZeroExit {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
status: output.status,
stdout: output.stdout,
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
}
fn read_to_end<R: Read>(mut reader: R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = reader.read_to_end(&mut buf);
buf
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_non_zero(stderr: &str) -> RunError {
#[cfg(unix)]
let status = {
use std::os::unix::process::ExitStatusExt;
std::process::ExitStatus::from_raw(256) };
#[cfg(windows)]
let status = {
use std::os::windows::process::ExitStatusExt;
std::process::ExitStatus::from_raw(1)
};
RunError::NonZeroExit {
program: "program".into(),
args: vec!["arg".into()],
status,
stdout: Vec::new(),
stderr: stderr.to_string(),
}
}
fn fake_spawn() -> RunError {
RunError::Spawn {
program: "program".into(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
}
}
#[test]
fn stdout_lossy_valid_utf8() {
let output = RunOutput {
stdout: b"hello world".to_vec(),
stderr: String::new(),
};
assert_eq!(output.stdout_lossy(), "hello world");
}
#[test]
fn stdout_lossy_invalid_utf8() {
let output = RunOutput {
stdout: vec![0xff, 0xfe, b'a', b'b'],
stderr: String::new(),
};
let s = output.stdout_lossy();
assert!(s.contains("ab"));
assert!(s.contains('\u{FFFD}'));
}
#[test]
fn stdout_raw_bytes_preserved() {
let bytes: Vec<u8> = (0..=255).collect();
let output = RunOutput {
stdout: bytes.clone(),
stderr: String::new(),
};
assert_eq!(output.stdout, bytes);
}
#[test]
fn run_output_debug_impl() {
let output = RunOutput {
stdout: b"hello".to_vec(),
stderr: "warn".to_string(),
};
let debug = format!("{output:?}");
assert!(debug.contains("warn"));
assert!(debug.contains("stdout"));
}
#[test]
fn binary_available_missing_returns_false() {
assert!(!binary_available("nonexistent_binary_xyz_42"));
}
#[test]
fn binary_version_missing_returns_none() {
assert!(binary_version("nonexistent_binary_xyz_42").is_none());
}
#[test]
fn retry_accepts_closure_over_run_error() {
let captured = "special".to_string();
let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
assert!(!checker(&fake_non_zero("other")));
assert!(checker(&fake_non_zero("this has special text")));
assert!(!checker(&fake_spawn()));
}
}