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", "gemini", "codex", "amp", "copilot", "opencode",
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-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!(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
59/// Checks if a backend is available by running its version command.
60///
61/// Each backend is detected by running `<command> --version` and checking
62/// for exit code 0. The command may differ from the backend name (e.g.,
63/// "kiro" backend uses "kiro-cli" command).
64pub 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
91/// Detects the first available backend from a priority list.
92///
93/// # Arguments
94/// * `priority` - List of backend names to check in order
95/// * `adapter_enabled` - Function that returns whether an adapter is enabled in config
96///
97/// # Returns
98/// * `Ok(backend_name)` - First available backend
99/// * `Err(NoBackendError)` - No backends available
100pub 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    // Check cache first
107    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        // Skip if adapter is disabled in config
118        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            // Cache the result (ignore if already set)
128            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    // Cache the failure too
135    let _ = DETECTED_BACKEND.set(None);
136
137    Err(NoBackendError { checked })
138}
139
140/// Detects a backend using default priority and all adapters enabled.
141pub 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        // 'echo' command should always be available
152        let result = Command::new("echo").arg("--version").output();
153        // Just verify the command runs without panic
154        assert!(result.is_ok());
155    }
156
157    #[test]
158    fn test_is_backend_available_nonexistent() {
159        // Nonexistent command should return false
160        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        // All adapters disabled should fail
168        let result = detect_backend(&["claude", "gemini"], |_| false);
169        // Should return error since all are disabled (empty checked list)
170        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        // Kiro backend uses kiro-cli as the command
189        assert_eq!(detection_command("kiro"), "kiro-cli");
190    }
191
192    #[test]
193    fn test_detection_command_others() {
194        // Other backends use their name as the command
195        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        // Test that default priority order is respected when no backends are available
204        // Use non-existent backends to ensure they all fail
205        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        // Should fail since no backends are actually available, but check the order
215        assert!(result.is_err());
216        if let Err(e) = result {
217            // Should check backends in the specified priority order
218            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        // Test that custom priority order is honored
234        let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
235        let result = detect_backend(custom_priority, |_| true);
236
237        // Should fail since no backends are actually available, but check the order
238        assert!(result.is_err());
239        if let Err(e) = result {
240            // Should check backends in custom priority order
241            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        // Test that disabled adapters are skipped even if in priority list
248        let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
249        let result = detect_backend(priority, |backend| {
250            // Only enable fake_gemini and fake_codex
251            matches!(backend, "fake_gemini" | "fake_codex")
252        });
253
254        // Should fail since no backends are actually available, but check only enabled ones were checked
255        assert!(result.is_err());
256        if let Err(e) = result {
257            // Should only check enabled backends (fake_gemini, fake_codex), skipping disabled ones (fake_claude, fake_kiro)
258            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        // Test priority ordering with some adapters disabled
265        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            // Disable fake_kiro and fake_codex
274            !matches!(backend, "fake_kiro" | "fake_codex")
275        });
276
277        // Should fail since no backends are actually available, but check the filtered order
278        assert!(result.is_err());
279        if let Err(e) = result {
280            // Should check in priority order but skip disabled ones
281            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        // Test behavior with empty priority list
288        let result = detect_backend(&[], |_| true);
289
290        // Should fail with empty checked list
291        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        // Test that all disabled adapters results in empty checked list
300        let priority = &["claude", "gemini", "kiro"];
301        let result = detect_backend(priority, |_| false);
302
303        // Should fail with empty checked list since all are disabled
304        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        // Test that the first available backend in priority order is selected
313        // Mix available and unavailable backends to test priority
314        let priority = &[
315            "fake_nonexistent1",
316            "fake_nonexistent2",
317            "echo",
318            "fake_nonexistent3",
319        ];
320        let result = detect_backend(priority, |_| true);
321
322        // Should succeed and return "echo" (first available in the priority list)
323        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        // Test that detection continues through priority list until it finds an available backend
332        let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
333        let result = detect_backend(priority, |backend| {
334            // Disable the first fake backend, enable the rest
335            backend != "fake_nonexistent1"
336        });
337
338        // Should succeed and return "echo" (first enabled and available)
339        assert!(result.is_ok());
340        if let Ok(backend) = result {
341            assert_eq!(backend, "echo");
342        }
343    }
344}