1use std::process::Command;
7use std::sync::OnceLock;
8use tracing::debug;
9
10pub const DEFAULT_PRIORITY: &[&str] = &[
12 "claude", "kiro", "gemini", "codex", "amp", "copilot", "opencode",
13];
14
15fn detection_command(backend: &str) -> &str {
20 match backend {
21 "kiro" => "kiro-cli",
22 _ => backend,
23 }
24}
25
26static DETECTED_BACKEND: OnceLock<Option<String>> = OnceLock::new();
28
29#[derive(Debug, Clone)]
31pub struct NoBackendError {
32 pub checked: Vec<String>,
34}
35
36impl std::fmt::Display for NoBackendError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 writeln!(f, "No supported AI backend found in PATH.")?;
39 writeln!(f)?;
40 writeln!(f, "Checked backends: {}", self.checked.join(", "))?;
41 writeln!(f)?;
42 writeln!(f, "Install one of the following:")?;
43 writeln!(
44 f,
45 " • Claude CLI: https://docs.anthropic.com/claude-code"
46 )?;
47 writeln!(f, " • Kiro CLI: https://kiro.dev")?;
48 writeln!(f, " • Gemini CLI: https://cloud.google.com/gemini")?;
49 writeln!(f, " • Codex CLI: https://openai.com/codex")?;
50 writeln!(f, " • Amp CLI: https://amp.dev")?;
51 writeln!(f, " • Copilot CLI: https://docs.github.com/copilot")?;
52 writeln!(f, " • OpenCode CLI: https://opencode.ai")?;
53 Ok(())
54 }
55}
56
57impl std::error::Error for NoBackendError {}
58
59pub fn is_backend_available(backend: &str) -> bool {
65 let command = detection_command(backend);
66 let result = Command::new(command).arg("--version").output();
67
68 match result {
69 Ok(output) => {
70 let available = output.status.success();
71 debug!(
72 backend = backend,
73 command = command,
74 available = available,
75 "Backend availability check"
76 );
77 available
78 }
79 Err(_) => {
80 debug!(
81 backend = backend,
82 command = command,
83 available = false,
84 "Backend not found in PATH"
85 );
86 false
87 }
88 }
89}
90
91pub fn detect_backend<F>(priority: &[&str], adapter_enabled: F) -> Result<String, NoBackendError>
101where
102 F: Fn(&str) -> bool,
103{
104 debug!(priority = ?priority, "Starting backend auto-detection");
105
106 if let Some(cached) = DETECTED_BACKEND.get()
108 && let Some(backend) = cached
109 {
110 debug!(backend = %backend, "Using cached backend detection result");
111 return Ok(backend.clone());
112 }
113
114 let mut checked = Vec::new();
115
116 for &backend in priority {
117 if !adapter_enabled(backend) {
119 debug!(backend = backend, "Skipping disabled adapter");
120 continue;
121 }
122
123 checked.push(backend.to_string());
124
125 if is_backend_available(backend) {
126 debug!(backend = backend, "Backend detected and selected");
127 let _ = DETECTED_BACKEND.set(Some(backend.to_string()));
129 return Ok(backend.to_string());
130 }
131 }
132
133 debug!(checked = ?checked, "No backends available");
134 let _ = DETECTED_BACKEND.set(None);
136
137 Err(NoBackendError { checked })
138}
139
140pub fn detect_backend_default() -> Result<String, NoBackendError> {
142 detect_backend(DEFAULT_PRIORITY, |_| true)
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_is_backend_available_echo() {
151 let result = Command::new("echo").arg("--version").output();
153 assert!(result.is_ok());
155 }
156
157 #[test]
158 fn test_is_backend_available_nonexistent() {
159 assert!(!is_backend_available(
161 "definitely_not_a_real_command_xyz123"
162 ));
163 }
164
165 #[test]
166 fn test_detect_backend_with_disabled_adapters() {
167 let result = detect_backend(&["claude", "gemini"], |_| false);
169 assert!(result.is_err());
171 if let Err(e) = result {
172 assert!(e.checked.is_empty());
173 }
174 }
175
176 #[test]
177 fn test_no_backend_error_display() {
178 let err = NoBackendError {
179 checked: vec!["claude".to_string(), "gemini".to_string()],
180 };
181 let msg = format!("{}", err);
182 assert!(msg.contains("No supported AI backend found"));
183 assert!(msg.contains("claude, gemini"));
184 }
185
186 #[test]
187 fn test_detection_command_kiro() {
188 assert_eq!(detection_command("kiro"), "kiro-cli");
190 }
191
192 #[test]
193 fn test_detection_command_others() {
194 assert_eq!(detection_command("claude"), "claude");
196 assert_eq!(detection_command("gemini"), "gemini");
197 assert_eq!(detection_command("codex"), "codex");
198 assert_eq!(detection_command("amp"), "amp");
199 }
200
201 #[test]
202 fn test_detect_backend_default_priority_order() {
203 let fake_priority = &[
206 "fake_claude",
207 "fake_kiro",
208 "fake_gemini",
209 "fake_codex",
210 "fake_amp",
211 ];
212 let result = detect_backend(fake_priority, |_| true);
213
214 assert!(result.is_err());
216 if let Err(e) = result {
217 assert_eq!(
219 e.checked,
220 vec![
221 "fake_claude",
222 "fake_kiro",
223 "fake_gemini",
224 "fake_codex",
225 "fake_amp"
226 ]
227 );
228 }
229 }
230
231 #[test]
232 fn test_detect_backend_custom_priority_order() {
233 let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
235 let result = detect_backend(custom_priority, |_| true);
236
237 assert!(result.is_err());
239 if let Err(e) = result {
240 assert_eq!(e.checked, vec!["fake_gemini", "fake_claude", "fake_amp"]);
242 }
243 }
244
245 #[test]
246 fn test_detect_backend_skips_disabled_adapters() {
247 let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
249 let result = detect_backend(priority, |backend| {
250 matches!(backend, "fake_gemini" | "fake_codex")
252 });
253
254 assert!(result.is_err());
256 if let Err(e) = result {
257 assert_eq!(e.checked, vec!["fake_gemini", "fake_codex"]);
259 }
260 }
261
262 #[test]
263 fn test_detect_backend_respects_priority_with_mixed_enabled() {
264 let priority = &[
266 "fake_claude",
267 "fake_kiro",
268 "fake_gemini",
269 "fake_codex",
270 "fake_amp",
271 ];
272 let result = detect_backend(priority, |backend| {
273 !matches!(backend, "fake_kiro" | "fake_codex")
275 });
276
277 assert!(result.is_err());
279 if let Err(e) = result {
280 assert_eq!(e.checked, vec!["fake_claude", "fake_gemini", "fake_amp"]);
282 }
283 }
284
285 #[test]
286 fn test_detect_backend_empty_priority_list() {
287 let result = detect_backend(&[], |_| true);
289
290 assert!(result.is_err());
292 if let Err(e) = result {
293 assert!(e.checked.is_empty());
294 }
295 }
296
297 #[test]
298 fn test_detect_backend_all_disabled() {
299 let priority = &["claude", "gemini", "kiro"];
301 let result = detect_backend(priority, |_| false);
302
303 assert!(result.is_err());
305 if let Err(e) = result {
306 assert!(e.checked.is_empty());
307 }
308 }
309
310 #[test]
311 fn test_detect_backend_finds_first_available() {
312 let priority = &[
315 "fake_nonexistent1",
316 "fake_nonexistent2",
317 "echo",
318 "fake_nonexistent3",
319 ];
320 let result = detect_backend(priority, |_| true);
321
322 assert!(result.is_ok());
324 if let Ok(backend) = result {
325 assert_eq!(backend, "echo");
326 }
327 }
328
329 #[test]
330 fn test_detect_backend_skips_to_next_available() {
331 let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
333 let result = detect_backend(priority, |backend| {
334 backend != "fake_nonexistent1"
336 });
337
338 assert!(result.is_ok());
340 if let Ok(backend) = result {
341 assert_eq!(backend, "echo");
342 }
343 }
344}