use std::env;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use crate::domain::agent::AgentKind;
#[cfg_attr(test, mockall::automock)]
pub trait AgentAvailabilityProbe: Send + Sync {
fn available_agent_kinds(&self) -> Vec<AgentKind>;
}
pub struct RealAgentAvailabilityProbe;
impl AgentAvailabilityProbe for RealAgentAvailabilityProbe {
fn available_agent_kinds(&self) -> Vec<AgentKind> {
available_agent_kinds_from_path(env::var_os("PATH").as_deref())
}
}
pub struct StaticAgentAvailabilityProbe {
pub available_agent_kinds: Vec<AgentKind>,
}
impl AgentAvailabilityProbe for StaticAgentAvailabilityProbe {
fn available_agent_kinds(&self) -> Vec<AgentKind> {
self.available_agent_kinds.clone()
}
}
#[must_use]
pub fn executable_name(agent_kind: AgentKind) -> &'static str {
match agent_kind {
AgentKind::Antigravity => "agy",
AgentKind::Gemini => "gemini",
AgentKind::Claude => "claude",
AgentKind::Codex => "codex",
}
}
fn available_agent_kinds_from_path(path_value: Option<&OsStr>) -> Vec<AgentKind> {
AgentKind::ALL
.iter()
.copied()
.filter(|agent_kind| is_executable_on_path(path_value, executable_name(*agent_kind)))
.collect()
}
fn is_executable_on_path(path_value: Option<&OsStr>, executable_name: &str) -> bool {
path_value
.map(env::split_paths)
.into_iter()
.flatten()
.map(|path_entry| candidate_path_for_executable_name(&path_entry, executable_name))
.any(|candidate_path| is_executable_file(&candidate_path))
}
fn candidate_path_for_executable_name(path_entry: &Path, executable_name: &str) -> PathBuf {
path_entry.join(executable_name)
}
fn is_executable_file(candidate_path: &Path) -> bool {
let Ok(metadata) = candidate_path.metadata() else {
return false;
};
if !metadata.is_file() {
return false;
}
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(test)]
mod tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
use super::*;
#[test]
fn test_executable_name_matches_agent_cli_names() {
assert_eq!(executable_name(AgentKind::Antigravity), "agy");
assert_eq!(executable_name(AgentKind::Gemini), "gemini");
assert_eq!(executable_name(AgentKind::Claude), "claude");
assert_eq!(executable_name(AgentKind::Codex), "codex");
}
#[test]
fn test_real_agent_availability_probe_filters_missing_executables() {
let temp_directory = tempdir().expect("failed to create temp dir");
let antigravity_path = temp_directory.path().join("agy");
let codex_path = temp_directory.path().join("codex");
let gemini_path = temp_directory.path().join("gemini");
fs::write(&antigravity_path, "").expect("failed to create agy executable");
fs::write(&codex_path, "").expect("failed to create codex executable");
fs::write(&gemini_path, "").expect("failed to create gemini executable");
fs::set_permissions(&antigravity_path, fs::Permissions::from_mode(0o755))
.expect("failed to mark agy executable");
fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o755))
.expect("failed to mark codex executable");
fs::set_permissions(&gemini_path, fs::Permissions::from_mode(0o755))
.expect("failed to mark gemini executable");
let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
assert_eq!(
available_agent_kinds,
vec![AgentKind::Gemini, AgentKind::Antigravity, AgentKind::Codex]
);
}
#[test]
fn test_real_agent_availability_probe_ignores_non_executable_files() {
let temp_directory = tempdir().expect("failed to create temp dir");
let codex_path = temp_directory.path().join("codex");
fs::write(&codex_path, "").expect("failed to create codex file");
fs::set_permissions(&codex_path, fs::Permissions::from_mode(0o644))
.expect("failed to mark codex non-executable");
let path_value = env::join_paths([temp_directory.path()]).expect("valid path");
let available_agent_kinds = available_agent_kinds_from_path(Some(path_value.as_os_str()));
assert!(available_agent_kinds.is_empty());
}
}