use std::io::Read;
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
const MAX_PROBE_TIMEOUT: Duration = Duration::from_secs(30);
const POLL_INTERVAL: Duration = Duration::from_millis(10);
const MAX_OUTPUT_BYTES: usize = 64 * 1024;
const OUTPUT_WAIT: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProbeStatus {
Tested,
Unavailable,
TimedOut,
Failed,
Unsupported,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentCheck {
pub agent: String,
pub command: Option<String>,
pub status: ProbeStatus,
pub version: Option<String>,
pub tested_registry: bool,
pub conflicts: Option<Vec<String>>,
pub message: String,
}
impl AgentCheck {
pub fn summary(&self) -> String {
let version = self.version.as_deref().unwrap_or("version unavailable");
let registry = if self.tested_registry {
match &self.conflicts {
Some(conflicts) if conflicts.is_empty() => "registry tested: no conflicts",
Some(_) => "registry tested: conflicts found",
None => "registry tested: result unavailable",
}
} else {
"registry not tested"
};
format!(
"{} ({:?}, {version}); {registry}: {}",
self.agent, self.status, self.message
)
}
}
pub fn probe_agent(agent: &str, timeout: Duration) -> AgentCheck {
let Some(command) = command_for_agent(agent) else {
return AgentCheck {
agent: agent.to_string(),
command: None,
status: ProbeStatus::Unsupported,
version: None,
tested_registry: false,
conflicts: None,
message: "no safe executable mapping for this agent".to_string(),
};
};
probe_command(agent, command, timeout)
}
pub fn probe(agent: &str, timeout: Duration) -> AgentCheck {
probe_agent(agent, timeout)
}
fn command_for_agent(agent: &str) -> Option<&'static str> {
match agent {
"codex" => Some("codex"),
"claude" => Some("claude"),
"aider" => Some("aider"),
"cursor" => Some("cursor-agent"),
"cline" => Some("cline"),
"opencode" => Some("opencode"),
"copilot" => Some("copilot"),
"custom" => None,
_ => None,
}
}
fn probe_command(agent: &str, command: &str, timeout: Duration) -> AgentCheck {
let bounded_timeout = timeout.min(MAX_PROBE_TIMEOUT);
let mut command_builder = Command::new(command);
command_builder
.arg("--version")
.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if let Some(path) = std::env::var_os("PATH") {
command_builder.env("PATH", path);
}
let mut child = match command_builder.spawn() {
Ok(child) => child,
Err(error) => {
let unavailable = error.kind() == std::io::ErrorKind::NotFound;
return AgentCheck {
agent: agent.to_string(),
command: Some(command.to_string()),
status: if unavailable {
ProbeStatus::Unavailable
} else {
ProbeStatus::Failed
},
version: None,
tested_registry: false,
conflicts: None,
message: format!("could not start probe: {error}"),
};
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_thread = spawn_reader(stdout);
let stderr_thread = spawn_reader(stderr);
let deadline = Instant::now() + bounded_timeout;
let mut timed_out = false;
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Some(status),
Ok(None) if Instant::now() >= deadline => {
timed_out = true;
let _ = child.kill();
break child.wait().ok();
}
Ok(None) => thread::sleep(POLL_INTERVAL),
Err(_) => {
let _ = child.kill();
let _ = child.wait();
break None;
}
}
};
let stdout = if timed_out {
String::new()
} else {
receive_output(stdout_thread)
};
let stderr = if timed_out {
String::new()
} else {
receive_output(stderr_thread)
};
let version = parse_version(&stdout).or_else(|| parse_version(&stderr));
let (probe_status, message) = if timed_out {
(
ProbeStatus::TimedOut,
format!(
"probe exceeded {} ms and was terminated",
bounded_timeout.as_millis()
),
)
} else if status.as_ref().is_some_and(|status| status.success()) {
(ProbeStatus::Tested, "version command completed".to_string())
} else if status.is_some() {
(
ProbeStatus::Failed,
"version command returned a failure status".to_string(),
)
} else {
(
ProbeStatus::Failed,
"probe process status was unavailable".to_string(),
)
};
AgentCheck {
agent: agent.to_string(),
command: Some(command.to_string()),
status: probe_status,
version,
tested_registry: false,
conflicts: None,
message,
}
}
fn spawn_reader<R: Read + Send + 'static>(stream: Option<R>) -> Receiver<String> {
let (sender, receiver) = mpsc::channel();
match stream {
Some(stream) => {
thread::spawn(move || {
let _ = sender.send(read_output(stream));
});
}
None => {
let _ = sender.send(String::new());
}
}
receiver
}
fn receive_output(receiver: Receiver<String>) -> String {
receiver.recv_timeout(OUTPUT_WAIT).unwrap_or_default()
}
fn read_output(mut stream: impl Read) -> String {
let mut output = Vec::new();
let mut buffer = [0_u8; 4096];
while let Ok(read) = stream.read(&mut buffer) {
if read == 0 {
break;
}
if output.len() < MAX_OUTPUT_BYTES {
let keep = read.min(MAX_OUTPUT_BYTES - output.len());
output.extend_from_slice(&buffer[..keep]);
}
}
String::from_utf8_lossy(&output).into_owned()
}
fn parse_version(output: &str) -> Option<String> {
output
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(|line| {
line.chars()
.filter(|ch| !ch.is_control())
.collect::<String>()
})
.map(|line| line.chars().take(256).collect::<String>())
.find(|line| !line.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_agents_are_reported_without_claiming_registry_results() {
let result = probe_agent("custom", Duration::from_millis(1));
assert_eq!(result.status, ProbeStatus::Unsupported);
assert!(!result.tested_registry);
assert!(result.conflicts.is_none());
assert!(result.summary().contains("registry not tested"));
}
#[test]
fn version_parser_ignores_empty_lines_and_control_bytes() {
assert_eq!(
parse_version("\n\u{1b}[?25lvetto 1.2\n"),
Some("[?25lvetto 1.2".to_string())
);
assert_eq!(parse_version("\n\n"), None);
}
#[test]
fn timeout_is_bounded_before_spawn_result_is_reported() {
let result = probe_command(
"test",
"vetto-command-that-does-not-exist",
Duration::from_secs(60),
);
assert_eq!(result.status, ProbeStatus::Unavailable);
assert!(!result.tested_registry);
}
}