Skip to main content

ralph_adapters/
auto_detect.rs

1//! Auto-detection logic for agent backends.
2//!
3//! When config specifies `agent: auto`, this module handles detecting
4//! which backends are available in the system PATH.
5
6use std::process::Command;
7use std::sync::OnceLock;
8use tracing::debug;
9
10/// Default priority order for backend detection.
11pub const DEFAULT_PRIORITY: &[&str] = &[
12    "claude", "kiro", "kiro-acp", "gemini", "codex", "amp", "copilot", "opencode", "pi", "roo",
13];
14
15/// Maps backend config names to their actual CLI command names.
16///
17/// Some backends have CLI binaries with different names than their config identifiers.
18/// For example, the "kiro" backend uses the "kiro-cli" binary.
19fn detection_command(backend: &str) -> &str {
20    match backend {
21        "kiro" | "kiro-acp" => "kiro-cli",
22        _ => backend,
23    }
24}
25
26/// Cached detection result for session duration.
27static DETECTED_BACKEND: OnceLock<Option<String>> = OnceLock::new();
28
29/// Error returned when no backends are available.
30#[derive(Debug, Clone)]
31pub struct NoBackendError {
32    /// Backends that were checked.
33    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
70/// Checks if a backend is available by running its version command.
71///
72/// Each backend is detected by running `<command> --version` and checking
73/// for exit code 0. The command may differ from the backend name (e.g.,
74/// "kiro" backend uses "kiro-cli" command).
75pub 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
102/// Detects the first available backend from a priority list.
103///
104/// # Arguments
105/// * `priority` - List of backend names to check in order
106/// * `adapter_enabled` - Function that returns whether an adapter is enabled in config
107///
108/// # Returns
109/// * `Ok(backend_name)` - First available backend
110/// * `Err(NoBackendError)` - No backends available
111pub 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    // Check cache first
118    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        // Skip if adapter is disabled in config
129        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            // Cache the result (ignore if already set)
139            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    // Cache the failure too
146    let _ = DETECTED_BACKEND.set(None);
147
148    Err(NoBackendError { checked })
149}
150
151/// Detects a backend using default priority and all adapters enabled.
152pub 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        // 'echo' command should always be available
163        let result = Command::new("echo").arg("--version").output();
164        // Just verify the command runs without panic
165        assert!(result.is_ok());
166    }
167
168    #[test]
169    fn test_is_backend_available_nonexistent() {
170        // Nonexistent command should return false
171        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        // All adapters disabled should fail
179        let result = detect_backend(&["claude", "gemini"], |_| false);
180        // Should return error since all are disabled (empty checked list)
181        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        // Kiro backend uses kiro-cli as the command
203        assert_eq!(detection_command("kiro"), "kiro-cli");
204    }
205
206    #[test]
207    fn test_detection_command_others() {
208        // Other backends use their name as the command
209        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        // Test that default priority order is respected when no backends are available
260        // Use non-existent backends to ensure they all fail
261        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        // Should fail since no backends are actually available, but check the order
271        assert!(result.is_err());
272        if let Err(e) = result {
273            // Should check backends in the specified priority order
274            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        // Test that custom priority order is honored
290        let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
291        let result = detect_backend(custom_priority, |_| true);
292
293        // Should fail since no backends are actually available, but check the order
294        assert!(result.is_err());
295        if let Err(e) = result {
296            // Should check backends in custom priority order
297            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        // Test that disabled adapters are skipped even if in priority list
304        let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
305        let result = detect_backend(priority, |backend| {
306            // Only enable fake_gemini and fake_codex
307            matches!(backend, "fake_gemini" | "fake_codex")
308        });
309
310        // Should fail since no backends are actually available, but check only enabled ones were checked
311        assert!(result.is_err());
312        if let Err(e) = result {
313            // Should only check enabled backends (fake_gemini, fake_codex), skipping disabled ones (fake_claude, fake_kiro)
314            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        // Test priority ordering with some adapters disabled
321        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            // Disable fake_kiro and fake_codex
330            !matches!(backend, "fake_kiro" | "fake_codex")
331        });
332
333        // Should fail since no backends are actually available, but check the filtered order
334        assert!(result.is_err());
335        if let Err(e) = result {
336            // Should check in priority order but skip disabled ones
337            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        // Test behavior with empty priority list
344        let result = detect_backend(&[], |_| true);
345
346        // Should fail with empty checked list
347        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        // Test that all disabled adapters results in empty checked list
356        let priority = &["claude", "gemini", "kiro"];
357        let result = detect_backend(priority, |_| false);
358
359        // Should fail with empty checked list since all are disabled
360        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        // Test that the first available backend in priority order is selected
369        // Mix available and unavailable backends to test priority
370        let priority = &[
371            "fake_nonexistent1",
372            "fake_nonexistent2",
373            "echo",
374            "fake_nonexistent3",
375        ];
376        let result = detect_backend(priority, |_| true);
377
378        // Should succeed and return "echo" (first available in the priority list)
379        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        // Test that detection continues through priority list until it finds an available backend
388        let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
389        let result = detect_backend(priority, |backend| {
390            // Disable the first fake backend, enable the rest
391            backend != "fake_nonexistent1"
392        });
393
394        // Should succeed and return "echo" (first enabled and available)
395        assert!(result.is_ok());
396        if let Ok(backend) = result {
397            assert_eq!(backend, "echo");
398        }
399    }
400}