use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc;
use std::sync::OnceLock;
use std::thread;
use std::time::{Duration, Instant};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ExternalTool {
Rg,
Fd,
Git,
Obscura,
}
impl ExternalTool {
fn command_name(self) -> &'static str {
match self {
Self::Rg => "rg",
Self::Fd => "fd",
Self::Git => "git",
Self::Obscura => "obscura",
}
}
pub fn backend_name(self) -> &'static str {
match self {
Self::Rg => "path-rg",
Self::Fd => "path-fd",
Self::Git => "path-git",
Self::Obscura => "path-obscura",
}
}
fn resolve_command(self) -> String {
if self == Self::Git {
return git_command().to_string();
}
self.command_name().to_string()
}
}
static GIT_COMMAND: OnceLock<String> = OnceLock::new();
fn git_command() -> &'static str {
GIT_COMMAND.get_or_init(|| {
let Some(found) = find_on_path("git") else {
return "git".to_string();
};
if let Some(parent) = found.parent()
&& parent.file_name().and_then(|name| name.to_str()) == Some("cmd")
&& let Some(root) = parent.parent()
{
let direct = root.join("mingw64").join("bin").join(if cfg!(windows) { "git.exe" } else { "git" });
if direct.is_file() {
return direct.to_string_lossy().into_owned();
}
}
found.to_string_lossy().into_owned()
})
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let file = if cfg!(windows) { format!("{name}.exe") } else { name.to_string() };
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
if dir.as_os_str().is_empty() {
continue;
}
let candidate = dir.join(&file);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
#[derive(Debug)]
pub struct ToolOutput {
pub status_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub backend: &'static str,
}
pub fn run_external(tool: ExternalTool, args: &[String], cwd: Option<&Path>, timeout_ms: Option<u64>) -> Result<ToolOutput, String> {
let timeout = Duration::from_millis(timeout_ms.unwrap_or(120_000));
run_path(tool, args, cwd, timeout)
}
fn run_path(tool: ExternalTool, args: &[String], cwd: Option<&Path>, timeout: Duration) -> Result<ToolOutput, String> {
let mut command = Command::new(tool.resolve_command());
command.args(args);
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let mut child = command.spawn().map_err(|error| format!("Failed to start {} from PATH: {error}. Ensure '{}' is installed and on PATH.", tool.backend_name(), tool.command_name()))?;
let stdout = child.stdout.take().ok_or_else(|| "Failed to capture stdout".to_string())?;
let stderr = child.stderr.take().ok_or_else(|| "Failed to capture stderr".to_string())?;
let (done_tx, done_rx) = mpsc::channel::<()>();
let stdout_done = done_tx.clone();
let stderr_done = done_tx.clone();
drop(done_tx);
let stdout_handle = thread::spawn(move || {
let result = read_pipe(stdout);
let _ = stdout_done.send(());
result
});
let stderr_handle = thread::spawn(move || {
let result = read_pipe(stderr);
let _ = stderr_done.send(());
result
});
let deadline = Instant::now() + timeout;
let mut closed = 0usize;
let mut timed_out = false;
while closed < 2 {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
timed_out = true;
break;
}
match done_rx.recv_timeout(remaining) {
Ok(()) => closed += 1,
Err(mpsc::RecvTimeoutError::Timeout) => {
timed_out = true;
break;
}
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
if timed_out {
let _ = child.kill();
let _ = child.wait();
return Err(format!("{} timed out after {}ms", tool.backend_name(), timeout.as_millis()));
}
let status = match child.wait() {
Ok(status) => status,
Err(error) => {
return Err(format!("Failed to wait for {}: {error}", tool.backend_name()));
}
};
let stdout = join_pipe(stdout_handle)?;
let stderr = join_pipe(stderr_handle)?;
Ok(ToolOutput { status_code: status.code(), stdout: String::from_utf8_lossy(&stdout).into_owned(), stderr: String::from_utf8_lossy(&stderr).into_owned(), backend: tool.backend_name() })
}
fn read_pipe<R: Read>(mut pipe: R) -> std::io::Result<Vec<u8>> {
let mut bytes = Vec::new();
pipe.read_to_end(&mut bytes)?;
Ok(bytes)
}
fn join_pipe(handle: thread::JoinHandle<std::io::Result<Vec<u8>>>) -> Result<Vec<u8>, String> {
handle.join().map_err(|_| "Failed to join external tool reader thread".to_string())?.map_err(|error| format!("Failed to read external tool output: {error}"))
}