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] = &["claude", "kiro", "gemini", "codex", "amp"];
12
13/// Maps backend config names to their actual CLI command names.
14///
15/// Some backends have CLI binaries with different names than their config identifiers.
16/// For example, the "kiro" backend uses the "kiro-cli" binary.
17fn detection_command(backend: &str) -> &str {
18    match backend {
19        "kiro" => "kiro-cli",
20        _ => backend,
21    }
22}
23
24/// Cached detection result for session duration.
25static DETECTED_BACKEND: OnceLock<Option<String>> = OnceLock::new();
26
27/// Error returned when no backends are available.
28#[derive(Debug, Clone)]
29pub struct NoBackendError {
30    /// Backends that were checked.
31    pub checked: Vec<String>,
32}
33
34impl std::fmt::Display for NoBackendError {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        writeln!(f, "No supported AI backend found in PATH.")?;
37        writeln!(f)?;
38        writeln!(f, "Checked backends: {}", self.checked.join(", "))?;
39        writeln!(f)?;
40        writeln!(f, "Install one of the following:")?;
41        writeln!(f, "  • Claude CLI: https://docs.anthropic.com/claude-code")?;
42        writeln!(f, "  • Kiro CLI:   https://kiro.dev")?;
43        writeln!(f, "  • Gemini CLI: https://cloud.google.com/gemini")?;
44        writeln!(f, "  • Codex CLI:  https://openai.com/codex")?;
45        writeln!(f, "  • Amp CLI:    https://amp.dev")?;
46        Ok(())
47    }
48}
49
50impl std::error::Error for NoBackendError {}
51
52/// Checks if a backend is available by running its version command.
53///
54/// Each backend is detected by running `<command> --version` and checking
55/// for exit code 0. The command may differ from the backend name (e.g.,
56/// "kiro" backend uses "kiro-cli" command).
57pub fn is_backend_available(backend: &str) -> bool {
58    let command = detection_command(backend);
59    let result = Command::new(command).arg("--version").output();
60
61    match result {
62        Ok(output) => {
63            let available = output.status.success();
64            debug!(backend = backend, command = command, available = available, "Backend availability check");
65            available
66        }
67        Err(_) => {
68            debug!(backend = backend, command = command, available = false, "Backend not found in PATH");
69            false
70        }
71    }
72}
73
74/// Detects the first available backend from a priority list.
75///
76/// # Arguments
77/// * `priority` - List of backend names to check in order
78/// * `adapter_enabled` - Function that returns whether an adapter is enabled in config
79///
80/// # Returns
81/// * `Ok(backend_name)` - First available backend
82/// * `Err(NoBackendError)` - No backends available
83pub fn detect_backend<F>(priority: &[&str], adapter_enabled: F) -> Result<String, NoBackendError>
84where
85    F: Fn(&str) -> bool,
86{
87    debug!(priority = ?priority, "Starting backend auto-detection");
88
89    // Check cache first
90    if let Some(cached) = DETECTED_BACKEND.get() {
91        if let Some(backend) = cached {
92            debug!(backend = %backend, "Using cached backend detection result");
93            return Ok(backend.clone());
94        }
95    }
96
97    let mut checked = Vec::new();
98
99    for &backend in priority {
100        // Skip if adapter is disabled in config
101        if !adapter_enabled(backend) {
102            debug!(backend = backend, "Skipping disabled adapter");
103            continue;
104        }
105
106        checked.push(backend.to_string());
107
108        if is_backend_available(backend) {
109            debug!(backend = backend, "Backend detected and selected");
110            // Cache the result (ignore if already set)
111            let _ = DETECTED_BACKEND.set(Some(backend.to_string()));
112            return Ok(backend.to_string());
113        }
114    }
115
116    debug!(checked = ?checked, "No backends available");
117    // Cache the failure too
118    let _ = DETECTED_BACKEND.set(None);
119
120    Err(NoBackendError { checked })
121}
122
123/// Detects a backend using default priority and all adapters enabled.
124pub fn detect_backend_default() -> Result<String, NoBackendError> {
125    detect_backend(DEFAULT_PRIORITY, |_| true)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_is_backend_available_echo() {
134        // 'echo' command should always be available
135        let result = Command::new("echo").arg("--version").output();
136        // Just verify the command runs without panic
137        assert!(result.is_ok());
138    }
139
140    #[test]
141    fn test_is_backend_available_nonexistent() {
142        // Nonexistent command should return false
143        assert!(!is_backend_available("definitely_not_a_real_command_xyz123"));
144    }
145
146    #[test]
147    fn test_detect_backend_with_disabled_adapters() {
148        // All adapters disabled should fail
149        let result = detect_backend(&["claude", "gemini"], |_| false);
150        // Should return error since all are disabled (empty checked list)
151        assert!(result.is_err());
152        if let Err(e) = result {
153            assert!(e.checked.is_empty());
154        }
155    }
156
157    #[test]
158    fn test_no_backend_error_display() {
159        let err = NoBackendError {
160            checked: vec!["claude".to_string(), "gemini".to_string()],
161        };
162        let msg = format!("{}", err);
163        assert!(msg.contains("No supported AI backend found"));
164        assert!(msg.contains("claude, gemini"));
165    }
166
167    #[test]
168    fn test_detection_command_kiro() {
169        // Kiro backend uses kiro-cli as the command
170        assert_eq!(detection_command("kiro"), "kiro-cli");
171    }
172
173    #[test]
174    fn test_detection_command_others() {
175        // Other backends use their name as the command
176        assert_eq!(detection_command("claude"), "claude");
177        assert_eq!(detection_command("gemini"), "gemini");
178        assert_eq!(detection_command("codex"), "codex");
179        assert_eq!(detection_command("amp"), "amp");
180    }
181
182    #[test]
183    fn test_detect_backend_default_priority_order() {
184        // Test that default priority order is respected when no backends are available
185        // Use non-existent backends to ensure they all fail
186        let fake_priority = &["fake_claude", "fake_kiro", "fake_gemini", "fake_codex", "fake_amp"];
187        let result = detect_backend(fake_priority, |_| true);
188        
189        // Should fail since no backends are actually available, but check the order
190        assert!(result.is_err());
191        if let Err(e) = result {
192            // Should check backends in the specified priority order
193            assert_eq!(e.checked, vec!["fake_claude", "fake_kiro", "fake_gemini", "fake_codex", "fake_amp"]);
194        }
195    }
196
197    #[test]
198    fn test_detect_backend_custom_priority_order() {
199        // Test that custom priority order is honored
200        let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
201        let result = detect_backend(custom_priority, |_| true);
202        
203        // Should fail since no backends are actually available, but check the order
204        assert!(result.is_err());
205        if let Err(e) = result {
206            // Should check backends in custom priority order
207            assert_eq!(e.checked, vec!["fake_gemini", "fake_claude", "fake_amp"]);
208        }
209    }
210
211    #[test]
212    fn test_detect_backend_skips_disabled_adapters() {
213        // Test that disabled adapters are skipped even if in priority list
214        let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
215        let result = detect_backend(priority, |backend| {
216            // Only enable fake_gemini and fake_codex
217            matches!(backend, "fake_gemini" | "fake_codex")
218        });
219        
220        // Should fail since no backends are actually available, but check only enabled ones were checked
221        assert!(result.is_err());
222        if let Err(e) = result {
223            // Should only check enabled backends (fake_gemini, fake_codex), skipping disabled ones (fake_claude, fake_kiro)
224            assert_eq!(e.checked, vec!["fake_gemini", "fake_codex"]);
225        }
226    }
227
228    #[test]
229    fn test_detect_backend_respects_priority_with_mixed_enabled() {
230        // Test priority ordering with some adapters disabled
231        let priority = &["fake_claude", "fake_kiro", "fake_gemini", "fake_codex", "fake_amp"];
232        let result = detect_backend(priority, |backend| {
233            // Disable fake_kiro and fake_codex
234            !matches!(backend, "fake_kiro" | "fake_codex")
235        });
236        
237        // Should fail since no backends are actually available, but check the filtered order
238        assert!(result.is_err());
239        if let Err(e) = result {
240            // Should check in priority order but skip disabled ones
241            assert_eq!(e.checked, vec!["fake_claude", "fake_gemini", "fake_amp"]);
242        }
243    }
244
245    #[test]
246    fn test_detect_backend_empty_priority_list() {
247        // Test behavior with empty priority list
248        let result = detect_backend(&[], |_| true);
249        
250        // Should fail with empty checked list
251        assert!(result.is_err());
252        if let Err(e) = result {
253            assert!(e.checked.is_empty());
254        }
255    }
256
257    #[test]
258    fn test_detect_backend_all_disabled() {
259        // Test that all disabled adapters results in empty checked list
260        let priority = &["claude", "gemini", "kiro"];
261        let result = detect_backend(priority, |_| false);
262        
263        // Should fail with empty checked list since all are disabled
264        assert!(result.is_err());
265        if let Err(e) = result {
266            assert!(e.checked.is_empty());
267        }
268    }
269
270    #[test]
271    fn test_detect_backend_finds_first_available() {
272        // Test that the first available backend in priority order is selected
273        // Mix available and unavailable backends to test priority
274        let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo", "fake_nonexistent3"];
275        let result = detect_backend(priority, |_| true);
276        
277        // Should succeed and return "echo" (first available in the priority list)
278        assert!(result.is_ok());
279        if let Ok(backend) = result {
280            assert_eq!(backend, "echo");
281        }
282    }
283
284    #[test]
285    fn test_detect_backend_skips_to_next_available() {
286        // Test that detection continues through priority list until it finds an available backend
287        let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
288        let result = detect_backend(priority, |backend| {
289            // Disable the first fake backend, enable the rest
290            backend != "fake_nonexistent1"
291        });
292        
293        // Should succeed and return "echo" (first enabled and available)
294        assert!(result.is_ok());
295        if let Ok(backend) = result {
296            assert_eq!(backend, "echo");
297        }
298    }
299}