use serde::{Deserialize, Serialize};
use crate::core::types::CliTool;
use crate::core::capabilities::CliCapabilities;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliProbe {
pub tool: CliTool,
pub installed: bool,
pub capabilities: CliCapabilities,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub probes: Vec<CliProbe>,
pub probed_at: i64,
}
impl ProbeResult {
pub fn for_tool(&self, tool: CliTool) -> Option<&CliProbe> {
self.probes.iter().find(|p| p.tool == tool)
}
pub fn installed(&self) -> Vec<&CliProbe> {
self.probes.iter().filter(|p| p.installed).collect()
}
pub fn any_installed(&self) -> bool {
self.probes.iter().any(|p| p.installed)
}
}
const DEFAULT_MAX_AGE_SECS: i64 = 3600;
pub fn probe_all() -> ProbeResult {
probe_with_max_age(DEFAULT_MAX_AGE_SECS)
}
pub fn probe_force() -> ProbeResult {
let result = do_probe();
let _ = write_cache(&result);
result
}
pub fn load_cached_probe() -> Option<ProbeResult> {
let path = cache_path()?;
let content = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn probe_with_max_age(max_age_secs: i64) -> ProbeResult {
if let Some(cached) = load_cached_probe() {
let now = chrono::Utc::now().timestamp();
if now - cached.probed_at < max_age_secs {
return cached;
}
}
let result = do_probe();
let _ = write_cache(&result);
result
}
fn do_probe() -> ProbeResult {
let tools = [
CliTool::ClaudeCode,
CliTool::Codex,
CliTool::Gemini,
CliTool::OpenCode,
];
let probes = tools.iter().map(|&tool| {
let binary = tool.capabilities().binary.clone();
let installed = binary_on_path(&binary);
let capabilities = if installed {
tool.discover_capabilities()
} else {
tool.capabilities()
};
CliProbe { tool, installed, capabilities }
}).collect();
ProbeResult {
probes,
probed_at: chrono::Utc::now().timestamp(),
}
}
fn cache_path() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
.map(std::path::PathBuf::from)?;
Some(home.join(".gate4agent").join("probe-cache.json"))
}
fn write_cache(result: &ProbeResult) -> std::io::Result<()> {
let path = cache_path().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "no home dir")
})?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(result)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json.as_bytes())?;
std::fs::rename(tmp, path)?;
Ok(())
}
fn binary_on_path(name: &str) -> bool {
let path_var = std::env::var_os("PATH").unwrap_or_default();
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join(name);
if candidate.is_file() { return true; }
if cfg!(windows) {
for ext in &["exe", "cmd", "bat"] {
let with_ext = dir.join(format!("{name}.{ext}"));
if with_ext.is_file() { return true; }
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_result_for_tool_finds_entry() {
let result = ProbeResult {
probes: vec![
CliProbe { tool: CliTool::ClaudeCode, installed: true, capabilities: CliTool::ClaudeCode.capabilities() },
CliProbe { tool: CliTool::Codex, installed: false, capabilities: CliTool::Codex.capabilities() },
],
probed_at: 0,
};
assert!(result.for_tool(CliTool::ClaudeCode).is_some());
assert!(result.for_tool(CliTool::Codex).is_some());
assert!(result.for_tool(CliTool::Gemini).is_none());
}
#[test]
fn installed_filters_correctly() {
let result = ProbeResult {
probes: vec![
CliProbe { tool: CliTool::ClaudeCode, installed: true, capabilities: CliTool::ClaudeCode.capabilities() },
CliProbe { tool: CliTool::Codex, installed: false, capabilities: CliTool::Codex.capabilities() },
CliProbe { tool: CliTool::Gemini, installed: true, capabilities: CliTool::Gemini.capabilities() },
],
probed_at: 0,
};
assert_eq!(result.installed().len(), 2);
assert!(result.any_installed());
}
#[test]
fn binary_on_path_returns_false_for_nonexistent() {
assert!(!binary_on_path("this_binary_definitely_does_not_exist_xyz_12345"));
}
#[test]
fn cache_roundtrip() {
let result = ProbeResult {
probes: vec![
CliProbe { tool: CliTool::ClaudeCode, installed: true, capabilities: CliTool::ClaudeCode.capabilities() },
],
probed_at: 1234567890,
};
let json = serde_json::to_string_pretty(&result).unwrap();
let parsed: ProbeResult = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.probes.len(), 1);
assert_eq!(parsed.probed_at, 1234567890);
assert!(parsed.probes[0].installed);
}
#[test]
fn do_probe_returns_four_entries() {
let result = do_probe();
assert_eq!(result.probes.len(), 4);
assert!(result.probed_at > 0);
}
}