use std::collections::BTreeMap;
use std::ffi::OsString;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
pub(crate) fn group_by_parent(files: &[PathBuf]) -> BTreeMap<PathBuf, Vec<PathBuf>> {
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
for file in files {
let parent = file.parent().unwrap_or(Path::new(".")).to_path_buf();
groups.entry(parent).or_default().push(file.clone());
}
groups
}
const POLL_INTERVAL: Duration = Duration::from_millis(5);
pub(crate) const TOOL_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub 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) fn execute(
tool: &'static str,
install_hint: &'static str,
program: &str,
args: &[OsString],
expected_outputs: &[&Path],
timeout: Duration,
) -> Result<(), ToolError> {
let spawned = Command::new(program)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut 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 stdout_reader = child.stdout.take().map(drain_on_thread);
let stderr_reader = child.stderr.take().map(drain_on_thread);
let collect = |reader: Option<std::thread::JoinHandle<Vec<u8>>>| {
reader
.and_then(|handle| handle.join().ok())
.unwrap_or_default()
};
let status = match wait_bounded(&mut child, timeout) {
Ok(Some(status)) => status,
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
collect(stdout_reader);
collect(stderr_reader);
return Err(ToolError::TimedOut { tool, timeout });
}
Err(e) => {
let _ = child.kill();
let _ = child.wait();
collect(stdout_reader);
collect(stderr_reader);
return Err(ToolError::Io(e));
}
};
let stderr = collect(stderr_reader);
collect(stdout_reader);
if !status.success() {
return Err(ToolError::ExitNonZero {
tool,
code: status.code(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
});
}
let produced_everything = expected_outputs
.iter()
.all(|path| matches!(std::fs::metadata(path), Ok(meta) if meta.len() > 0));
if produced_everything {
Ok(())
} else {
Err(ToolError::NoOutput { tool })
}
}
fn drain_on_thread<R: Read + Send + 'static>(mut pipe: R) -> std::thread::JoinHandle<Vec<u8>> {
std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = pipe.read_to_end(&mut buffer);
buffer
})
}
fn wait_bounded(child: &mut Child, timeout: Duration) -> std::io::Result<Option<ExitStatus>> {
let deadline = Instant::now() + timeout;
loop {
if let Some(status) = child.try_wait()? {
return Ok(Some(status));
}
if Instant::now() >= deadline {
return Ok(None);
}
std::thread::sleep(POLL_INTERVAL);
}
}
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)]
#[path = "../tests/unit/tool.rs"]
mod tests;