use std::ffi::OsString;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub(crate) enum ToolError {
NotFound {
tool: &'static str,
install_hint: &'static str,
},
ExitNonZero {
tool: &'static str,
code: Option<i32>,
stderr: String,
},
TimedOut {
tool: &'static str,
timeout: Duration,
},
NoOutput { tool: &'static str },
Io(std::io::Error),
}
impl std::fmt::Display for ToolError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ToolError::NotFound { tool, install_hint } => {
write!(f, "{tool} not found on PATH ({install_hint})")
}
ToolError::ExitNonZero { tool, code, stderr } => {
write!(f, "{tool} exited with code {code:?}: {stderr}")
}
ToolError::TimedOut { tool, timeout } => {
write!(f, "{tool} timed out after {timeout:?}")
}
ToolError::NoOutput { tool } => {
write!(f, "{tool} exited successfully but produced no output")
}
ToolError::Io(e) => write!(f, "failed to run external tool: {e}"),
}
}
}
impl std::error::Error for ToolError {}
pub(crate) async fn execute(
tool: &'static str,
install_hint: &'static str,
program: &str,
args: &[OsString],
output_path: &Path,
timeout: Duration,
) -> Result<(), ToolError> {
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn();
let child = match spawned {
Ok(child) => child,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(ToolError::NotFound { tool, install_hint });
}
Err(e) => return Err(ToolError::Io(e)),
};
let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => output,
Ok(Err(e)) => return Err(ToolError::Io(e)),
Err(_) => return Err(ToolError::TimedOut { tool, timeout }),
};
if !output.status.success() {
return Err(ToolError::ExitNonZero {
tool,
code: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
match tokio::fs::metadata(output_path).await {
Ok(meta) if meta.len() > 0 => Ok(()),
_ => Err(ToolError::NoOutput { tool }),
}
}
pub(crate) fn locate_on_path(binary_name: &str) -> bool {
let Some(path_var) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path_var).any(|dir| is_executable_file(&dir.join(binary_name)))
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(path)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sh_args(script: &str) -> Vec<OsString> {
vec![OsString::from("-c"), OsString::from(script)]
}
#[tokio::test]
async fn succeeds_when_process_writes_output_and_exits_zero() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("out.txt");
let script = format!("printf hi > {}", output.display());
let result = execute(
"sh-test",
"n/a",
"sh",
&sh_args(&script),
&output,
TOOL_TIMEOUT,
)
.await;
assert!(result.is_ok(), "{result:?}");
}
#[tokio::test]
async fn maps_nonzero_exit_to_exit_non_zero_with_captured_stderr() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("out.txt");
let result = execute(
"sh-test",
"n/a",
"sh",
&sh_args("echo boom 1>&2; exit 3"),
&output,
TOOL_TIMEOUT,
)
.await;
match result {
Err(ToolError::ExitNonZero { code, stderr, .. }) => {
assert_eq!(code, Some(3));
assert!(stderr.contains("boom"), "stderr was: {stderr}");
}
other => panic!("expected ExitNonZero, got {other:?}"),
}
}
#[tokio::test]
async fn maps_missing_binary_to_not_found() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("out.txt");
let result = execute(
"missing-test",
"install it from nowhere",
"definitely-not-a-real-binary-9f3c2a",
&[],
&output,
TOOL_TIMEOUT,
)
.await;
assert!(
matches!(result, Err(ToolError::NotFound { .. })),
"{result:?}"
);
}
#[tokio::test]
async fn zero_exit_with_no_output_file_is_no_output() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("out.txt");
let result = execute(
"sh-test",
"n/a",
"sh",
&sh_args("true"),
&output,
TOOL_TIMEOUT,
)
.await;
assert!(
matches!(result, Err(ToolError::NoOutput { .. })),
"{result:?}"
);
}
#[tokio::test]
async fn timed_out_process_is_reported() {
let dir = TempDir::new().unwrap();
let output = dir.path().join("out.txt");
let result = execute(
"sh-test",
"n/a",
"sh",
&sh_args("sleep 5"),
&output,
Duration::from_millis(50),
)
.await;
assert!(
matches!(result, Err(ToolError::TimedOut { .. })),
"{result:?}"
);
}
#[test]
fn locates_a_binary_known_to_exist_on_path() {
assert!(
locate_on_path("sh"),
"sh should be on PATH in any test environment"
);
}
#[test]
fn does_not_locate_a_nonexistent_binary() {
assert!(!locate_on_path("definitely-not-a-real-binary-9f3c2a"));
}
}