1use std::process::Command;
7use std::sync::OnceLock;
8use tracing::debug;
9
10pub const DEFAULT_PRIORITY: &[&str] = &[
12 "claude", "kiro", "kiro-acp", "gemini", "codex", "amp", "copilot", "opencode", "pi", "roo",
13];
14
15fn detection_command(backend: &str) -> &str {
20 match backend {
21 "kiro" | "kiro-acp" => "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!(
43 f,
44 "Fix: install a backend CLI or run `ralph doctor` to validate your setup."
45 )?;
46 writeln!(f, "See: docs/reference/troubleshooting.md#agent-not-found")?;
47 writeln!(f)?;
48 writeln!(f, "Install one of the following:")?;
49 writeln!(
50 f,
51 " • Claude CLI: https://docs.anthropic.com/claude-code"
52 )?;
53 writeln!(f, " • Kiro CLI: https://kiro.dev")?;
54 writeln!(f, " • Gemini CLI: https://cloud.google.com/gemini")?;
55 writeln!(f, " • Codex CLI: https://openai.com/codex")?;
56 writeln!(f, " • Amp CLI: https://amp.dev")?;
57 writeln!(f, " • Copilot CLI: https://docs.github.com/copilot")?;
58 writeln!(f, " • OpenCode CLI: https://opencode.ai")?;
59 writeln!(
60 f,
61 " • Pi CLI: https://github.com/anthropics/pi-coding-agent"
62 )?;
63 writeln!(f, " • Roo CLI: https://github.com/RooVetGit/Roo-Code")?;
64 Ok(())
65 }
66}
67
68impl std::error::Error for NoBackendError {}
69
70pub fn is_backend_available(backend: &str) -> bool {
76 let command = detection_command(backend);
77 let result = Command::new(command).arg("--version").output();
78
79 match result {
80 Ok(output) => {
81 let available = output.status.success();
82 debug!(
83 backend = backend,
84 command = command,
85 available = available,
86 "Backend availability check"
87 );
88 available
89 }
90 Err(_) => {
91 debug!(
92 backend = backend,
93 command = command,
94 available = false,
95 "Backend not found in PATH"
96 );
97 false
98 }
99 }
100}
101
102pub fn detect_backend<F>(priority: &[&str], adapter_enabled: F) -> Result<String, NoBackendError>
112where
113 F: Fn(&str) -> bool,
114{
115 debug!(priority = ?priority, "Starting backend auto-detection");
116
117 if let Some(cached) = DETECTED_BACKEND.get()
119 && let Some(backend) = cached
120 {
121 debug!(backend = %backend, "Using cached backend detection result");
122 return Ok(backend.clone());
123 }
124
125 let mut checked = Vec::new();
126
127 for &backend in priority {
128 if !adapter_enabled(backend) {
130 debug!(backend = backend, "Skipping disabled adapter");
131 continue;
132 }
133
134 checked.push(backend.to_string());
135
136 if is_backend_available(backend) {
137 debug!(backend = backend, "Backend detected and selected");
138 let _ = DETECTED_BACKEND.set(Some(backend.to_string()));
140 return Ok(backend.to_string());
141 }
142 }
143
144 debug!(checked = ?checked, "No backends available");
145 let _ = DETECTED_BACKEND.set(None);
147
148 Err(NoBackendError { checked })
149}
150
151pub fn detect_backend_default() -> Result<String, NoBackendError> {
153 detect_backend(DEFAULT_PRIORITY, |_| true)
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 #[test]
161 fn test_is_backend_available_echo() {
162 let result = Command::new("echo").arg("--version").output();
164 assert!(result.is_ok());
166 }
167
168 #[test]
169 fn test_is_backend_available_nonexistent() {
170 assert!(!is_backend_available(
172 "definitely_not_a_real_command_xyz123"
173 ));
174 }
175
176 #[test]
177 fn test_detect_backend_with_disabled_adapters() {
178 let result = detect_backend(&["claude", "gemini"], |_| false);
180 assert!(result.is_err());
182 if let Err(e) = result {
183 assert!(e.checked.is_empty());
184 }
185 }
186
187 #[test]
188 fn test_no_backend_error_display() {
189 let err = NoBackendError {
190 checked: vec!["claude".to_string(), "gemini".to_string()],
191 };
192 let msg = format!("{}", err);
193 assert!(msg.contains("No supported AI backend found"));
194 assert!(msg.contains("claude, gemini"));
195 assert!(msg.contains("ralph doctor"));
196 assert!(msg.contains("docs/reference/troubleshooting.md#agent-not-found"));
197 assert!(msg.contains("Pi CLI"));
198 }
199
200 #[test]
201 fn test_detection_command_kiro() {
202 assert_eq!(detection_command("kiro"), "kiro-cli");
204 }
205
206 #[test]
207 fn test_detection_command_others() {
208 assert_eq!(detection_command("claude"), "claude");
210 assert_eq!(detection_command("gemini"), "gemini");
211 assert_eq!(detection_command("codex"), "codex");
212 assert_eq!(detection_command("amp"), "amp");
213 assert_eq!(detection_command("pi"), "pi");
214 assert_eq!(detection_command("roo"), "roo");
215 }
216
217 #[test]
218 fn test_default_priority_includes_pi() {
219 assert!(
220 DEFAULT_PRIORITY.contains(&"pi"),
221 "DEFAULT_PRIORITY should include 'pi'"
222 );
223 }
224
225 #[test]
226 fn test_default_priority_pi_is_second_to_last() {
227 let len = DEFAULT_PRIORITY.len();
228 assert_eq!(
229 DEFAULT_PRIORITY[len - 2],
230 "pi",
231 "Pi should be second-to-last in DEFAULT_PRIORITY"
232 );
233 }
234
235 #[test]
236 fn test_default_priority_includes_roo() {
237 assert!(
238 DEFAULT_PRIORITY.contains(&"roo"),
239 "DEFAULT_PRIORITY should include 'roo'"
240 );
241 }
242
243 #[test]
244 fn test_default_priority_roo_is_last() {
245 assert_eq!(
246 DEFAULT_PRIORITY.last(),
247 Some(&"roo"),
248 "Roo should be the last entry in DEFAULT_PRIORITY"
249 );
250 }
251
252 #[test]
253 fn test_detection_command_roo() {
254 assert_eq!(detection_command("roo"), "roo");
255 }
256
257 #[test]
258 fn test_detect_backend_default_priority_order() {
259 let fake_priority = &[
262 "fake_claude",
263 "fake_kiro",
264 "fake_gemini",
265 "fake_codex",
266 "fake_amp",
267 ];
268 let result = detect_backend(fake_priority, |_| true);
269
270 assert!(result.is_err());
272 if let Err(e) = result {
273 assert_eq!(
275 e.checked,
276 vec![
277 "fake_claude",
278 "fake_kiro",
279 "fake_gemini",
280 "fake_codex",
281 "fake_amp"
282 ]
283 );
284 }
285 }
286
287 #[test]
288 fn test_detect_backend_custom_priority_order() {
289 let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
291 let result = detect_backend(custom_priority, |_| true);
292
293 assert!(result.is_err());
295 if let Err(e) = result {
296 assert_eq!(e.checked, vec!["fake_gemini", "fake_claude", "fake_amp"]);
298 }
299 }
300
301 #[test]
302 fn test_detect_backend_skips_disabled_adapters() {
303 let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
305 let result = detect_backend(priority, |backend| {
306 matches!(backend, "fake_gemini" | "fake_codex")
308 });
309
310 assert!(result.is_err());
312 if let Err(e) = result {
313 assert_eq!(e.checked, vec!["fake_gemini", "fake_codex"]);
315 }
316 }
317
318 #[test]
319 fn test_detect_backend_respects_priority_with_mixed_enabled() {
320 let priority = &[
322 "fake_claude",
323 "fake_kiro",
324 "fake_gemini",
325 "fake_codex",
326 "fake_amp",
327 ];
328 let result = detect_backend(priority, |backend| {
329 !matches!(backend, "fake_kiro" | "fake_codex")
331 });
332
333 assert!(result.is_err());
335 if let Err(e) = result {
336 assert_eq!(e.checked, vec!["fake_claude", "fake_gemini", "fake_amp"]);
338 }
339 }
340
341 #[test]
342 fn test_detect_backend_empty_priority_list() {
343 let result = detect_backend(&[], |_| true);
345
346 assert!(result.is_err());
348 if let Err(e) = result {
349 assert!(e.checked.is_empty());
350 }
351 }
352
353 #[test]
354 fn test_detect_backend_all_disabled() {
355 let priority = &["claude", "gemini", "kiro"];
357 let result = detect_backend(priority, |_| false);
358
359 assert!(result.is_err());
361 if let Err(e) = result {
362 assert!(e.checked.is_empty());
363 }
364 }
365
366 #[test]
367 fn test_detect_backend_finds_first_available() {
368 let priority = &[
371 "fake_nonexistent1",
372 "fake_nonexistent2",
373 "echo",
374 "fake_nonexistent3",
375 ];
376 let result = detect_backend(priority, |_| true);
377
378 assert!(result.is_ok());
380 if let Ok(backend) = result {
381 assert_eq!(backend, "echo");
382 }
383 }
384
385 #[test]
386 fn test_detect_backend_skips_to_next_available() {
387 let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
389 let result = detect_backend(priority, |backend| {
390 backend != "fake_nonexistent1"
392 });
393
394 assert!(result.is_ok());
396 if let Ok(backend) = result {
397 assert_eq!(backend, "echo");
398 }
399 }
400}