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!(
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 Ok(())
60 }
61}
62
63impl std::error::Error for NoBackendError {}
64
65pub fn is_backend_available(backend: &str) -> bool {
71 let command = detection_command(backend);
72 let result = Command::new(command).arg("--version").output();
73
74 match result {
75 Ok(output) => {
76 let available = output.status.success();
77 debug!(
78 backend = backend,
79 command = command,
80 available = available,
81 "Backend availability check"
82 );
83 available
84 }
85 Err(_) => {
86 debug!(
87 backend = backend,
88 command = command,
89 available = false,
90 "Backend not found in PATH"
91 );
92 false
93 }
94 }
95}
96
97pub fn detect_backend<F>(priority: &[&str], adapter_enabled: F) -> Result<String, NoBackendError>
107where
108 F: Fn(&str) -> bool,
109{
110 debug!(priority = ?priority, "Starting backend auto-detection");
111
112 if let Some(cached) = DETECTED_BACKEND.get()
114 && let Some(backend) = cached
115 {
116 debug!(backend = %backend, "Using cached backend detection result");
117 return Ok(backend.clone());
118 }
119
120 let mut checked = Vec::new();
121
122 for &backend in priority {
123 if !adapter_enabled(backend) {
125 debug!(backend = backend, "Skipping disabled adapter");
126 continue;
127 }
128
129 checked.push(backend.to_string());
130
131 if is_backend_available(backend) {
132 debug!(backend = backend, "Backend detected and selected");
133 let _ = DETECTED_BACKEND.set(Some(backend.to_string()));
135 return Ok(backend.to_string());
136 }
137 }
138
139 debug!(checked = ?checked, "No backends available");
140 let _ = DETECTED_BACKEND.set(None);
142
143 Err(NoBackendError { checked })
144}
145
146pub fn detect_backend_default() -> Result<String, NoBackendError> {
148 detect_backend(DEFAULT_PRIORITY, |_| true)
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154
155 #[test]
156 fn test_is_backend_available_echo() {
157 let result = Command::new("echo").arg("--version").output();
159 assert!(result.is_ok());
161 }
162
163 #[test]
164 fn test_is_backend_available_nonexistent() {
165 assert!(!is_backend_available(
167 "definitely_not_a_real_command_xyz123"
168 ));
169 }
170
171 #[test]
172 fn test_detect_backend_with_disabled_adapters() {
173 let result = detect_backend(&["claude", "gemini"], |_| false);
175 assert!(result.is_err());
177 if let Err(e) = result {
178 assert!(e.checked.is_empty());
179 }
180 }
181
182 #[test]
183 fn test_no_backend_error_display() {
184 let err = NoBackendError {
185 checked: vec!["claude".to_string(), "gemini".to_string()],
186 };
187 let msg = format!("{}", err);
188 assert!(msg.contains("No supported AI backend found"));
189 assert!(msg.contains("claude, gemini"));
190 assert!(msg.contains("ralph doctor"));
191 assert!(msg.contains("docs/reference/troubleshooting.md#agent-not-found"));
192 }
193
194 #[test]
195 fn test_detection_command_kiro() {
196 assert_eq!(detection_command("kiro"), "kiro-cli");
198 }
199
200 #[test]
201 fn test_detection_command_others() {
202 assert_eq!(detection_command("claude"), "claude");
204 assert_eq!(detection_command("gemini"), "gemini");
205 assert_eq!(detection_command("codex"), "codex");
206 assert_eq!(detection_command("amp"), "amp");
207 }
208
209 #[test]
210 fn test_detect_backend_default_priority_order() {
211 let fake_priority = &[
214 "fake_claude",
215 "fake_kiro",
216 "fake_gemini",
217 "fake_codex",
218 "fake_amp",
219 ];
220 let result = detect_backend(fake_priority, |_| true);
221
222 assert!(result.is_err());
224 if let Err(e) = result {
225 assert_eq!(
227 e.checked,
228 vec![
229 "fake_claude",
230 "fake_kiro",
231 "fake_gemini",
232 "fake_codex",
233 "fake_amp"
234 ]
235 );
236 }
237 }
238
239 #[test]
240 fn test_detect_backend_custom_priority_order() {
241 let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
243 let result = detect_backend(custom_priority, |_| true);
244
245 assert!(result.is_err());
247 if let Err(e) = result {
248 assert_eq!(e.checked, vec!["fake_gemini", "fake_claude", "fake_amp"]);
250 }
251 }
252
253 #[test]
254 fn test_detect_backend_skips_disabled_adapters() {
255 let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
257 let result = detect_backend(priority, |backend| {
258 matches!(backend, "fake_gemini" | "fake_codex")
260 });
261
262 assert!(result.is_err());
264 if let Err(e) = result {
265 assert_eq!(e.checked, vec!["fake_gemini", "fake_codex"]);
267 }
268 }
269
270 #[test]
271 fn test_detect_backend_respects_priority_with_mixed_enabled() {
272 let priority = &[
274 "fake_claude",
275 "fake_kiro",
276 "fake_gemini",
277 "fake_codex",
278 "fake_amp",
279 ];
280 let result = detect_backend(priority, |backend| {
281 !matches!(backend, "fake_kiro" | "fake_codex")
283 });
284
285 assert!(result.is_err());
287 if let Err(e) = result {
288 assert_eq!(e.checked, vec!["fake_claude", "fake_gemini", "fake_amp"]);
290 }
291 }
292
293 #[test]
294 fn test_detect_backend_empty_priority_list() {
295 let result = detect_backend(&[], |_| true);
297
298 assert!(result.is_err());
300 if let Err(e) = result {
301 assert!(e.checked.is_empty());
302 }
303 }
304
305 #[test]
306 fn test_detect_backend_all_disabled() {
307 let priority = &["claude", "gemini", "kiro"];
309 let result = detect_backend(priority, |_| false);
310
311 assert!(result.is_err());
313 if let Err(e) = result {
314 assert!(e.checked.is_empty());
315 }
316 }
317
318 #[test]
319 fn test_detect_backend_finds_first_available() {
320 let priority = &[
323 "fake_nonexistent1",
324 "fake_nonexistent2",
325 "echo",
326 "fake_nonexistent3",
327 ];
328 let result = detect_backend(priority, |_| true);
329
330 assert!(result.is_ok());
332 if let Ok(backend) = result {
333 assert_eq!(backend, "echo");
334 }
335 }
336
337 #[test]
338 fn test_detect_backend_skips_to_next_available() {
339 let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
341 let result = detect_backend(priority, |backend| {
342 backend != "fake_nonexistent1"
344 });
345
346 assert!(result.is_ok());
348 if let Ok(backend) = result {
349 assert_eq!(backend, "echo");
350 }
351 }
352}