use std::process::Command;
use std::sync::OnceLock;
use tracing::debug;
pub const DEFAULT_PRIORITY: &[&str] = &[
"claude", "kiro", "gemini", "codex", "amp", "copilot", "opencode",
];
fn detection_command(backend: &str) -> &str {
match backend {
"kiro" => "kiro-cli",
_ => backend,
}
}
static DETECTED_BACKEND: OnceLock<Option<String>> = OnceLock::new();
#[derive(Debug, Clone)]
pub struct NoBackendError {
pub checked: Vec<String>,
}
impl std::fmt::Display for NoBackendError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "No supported AI backend found in PATH.")?;
writeln!(f)?;
writeln!(f, "Checked backends: {}", self.checked.join(", "))?;
writeln!(f)?;
writeln!(f, "Install one of the following:")?;
writeln!(
f,
" • Claude CLI: https://docs.anthropic.com/claude-code"
)?;
writeln!(f, " • Kiro CLI: https://kiro.dev")?;
writeln!(f, " • Gemini CLI: https://cloud.google.com/gemini")?;
writeln!(f, " • Codex CLI: https://openai.com/codex")?;
writeln!(f, " • Amp CLI: https://amp.dev")?;
writeln!(f, " • Copilot CLI: https://docs.github.com/copilot")?;
writeln!(f, " • OpenCode CLI: https://opencode.ai")?;
Ok(())
}
}
impl std::error::Error for NoBackendError {}
pub fn is_backend_available(backend: &str) -> bool {
let command = detection_command(backend);
let result = Command::new(command).arg("--version").output();
match result {
Ok(output) => {
let available = output.status.success();
debug!(
backend = backend,
command = command,
available = available,
"Backend availability check"
);
available
}
Err(_) => {
debug!(
backend = backend,
command = command,
available = false,
"Backend not found in PATH"
);
false
}
}
}
pub fn detect_backend<F>(priority: &[&str], adapter_enabled: F) -> Result<String, NoBackendError>
where
F: Fn(&str) -> bool,
{
debug!(priority = ?priority, "Starting backend auto-detection");
if let Some(cached) = DETECTED_BACKEND.get()
&& let Some(backend) = cached
{
debug!(backend = %backend, "Using cached backend detection result");
return Ok(backend.clone());
}
let mut checked = Vec::new();
for &backend in priority {
if !adapter_enabled(backend) {
debug!(backend = backend, "Skipping disabled adapter");
continue;
}
checked.push(backend.to_string());
if is_backend_available(backend) {
debug!(backend = backend, "Backend detected and selected");
let _ = DETECTED_BACKEND.set(Some(backend.to_string()));
return Ok(backend.to_string());
}
}
debug!(checked = ?checked, "No backends available");
let _ = DETECTED_BACKEND.set(None);
Err(NoBackendError { checked })
}
pub fn detect_backend_default() -> Result<String, NoBackendError> {
detect_backend(DEFAULT_PRIORITY, |_| true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_backend_available_echo() {
let result = Command::new("echo").arg("--version").output();
assert!(result.is_ok());
}
#[test]
fn test_is_backend_available_nonexistent() {
assert!(!is_backend_available(
"definitely_not_a_real_command_xyz123"
));
}
#[test]
fn test_detect_backend_with_disabled_adapters() {
let result = detect_backend(&["claude", "gemini"], |_| false);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.checked.is_empty());
}
}
#[test]
fn test_no_backend_error_display() {
let err = NoBackendError {
checked: vec!["claude".to_string(), "gemini".to_string()],
};
let msg = format!("{}", err);
assert!(msg.contains("No supported AI backend found"));
assert!(msg.contains("claude, gemini"));
}
#[test]
fn test_detection_command_kiro() {
assert_eq!(detection_command("kiro"), "kiro-cli");
}
#[test]
fn test_detection_command_others() {
assert_eq!(detection_command("claude"), "claude");
assert_eq!(detection_command("gemini"), "gemini");
assert_eq!(detection_command("codex"), "codex");
assert_eq!(detection_command("amp"), "amp");
}
#[test]
fn test_detect_backend_default_priority_order() {
let fake_priority = &[
"fake_claude",
"fake_kiro",
"fake_gemini",
"fake_codex",
"fake_amp",
];
let result = detect_backend(fake_priority, |_| true);
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(
e.checked,
vec![
"fake_claude",
"fake_kiro",
"fake_gemini",
"fake_codex",
"fake_amp"
]
);
}
}
#[test]
fn test_detect_backend_custom_priority_order() {
let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
let result = detect_backend(custom_priority, |_| true);
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.checked, vec!["fake_gemini", "fake_claude", "fake_amp"]);
}
}
#[test]
fn test_detect_backend_skips_disabled_adapters() {
let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
let result = detect_backend(priority, |backend| {
matches!(backend, "fake_gemini" | "fake_codex")
});
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.checked, vec!["fake_gemini", "fake_codex"]);
}
}
#[test]
fn test_detect_backend_respects_priority_with_mixed_enabled() {
let priority = &[
"fake_claude",
"fake_kiro",
"fake_gemini",
"fake_codex",
"fake_amp",
];
let result = detect_backend(priority, |backend| {
!matches!(backend, "fake_kiro" | "fake_codex")
});
assert!(result.is_err());
if let Err(e) = result {
assert_eq!(e.checked, vec!["fake_claude", "fake_gemini", "fake_amp"]);
}
}
#[test]
fn test_detect_backend_empty_priority_list() {
let result = detect_backend(&[], |_| true);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.checked.is_empty());
}
}
#[test]
fn test_detect_backend_all_disabled() {
let priority = &["claude", "gemini", "kiro"];
let result = detect_backend(priority, |_| false);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.checked.is_empty());
}
}
#[test]
fn test_detect_backend_finds_first_available() {
let priority = &[
"fake_nonexistent1",
"fake_nonexistent2",
"echo",
"fake_nonexistent3",
];
let result = detect_backend(priority, |_| true);
assert!(result.is_ok());
if let Ok(backend) = result {
assert_eq!(backend, "echo");
}
}
#[test]
fn test_detect_backend_skips_to_next_available() {
let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
let result = detect_backend(priority, |backend| {
backend != "fake_nonexistent1"
});
assert!(result.is_ok());
if let Ok(backend) = result {
assert_eq!(backend, "echo");
}
}
}