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!(
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
65/// Checks if a backend is available by running its version command.
66///
67/// Each backend is detected by running `<command> --version` and checking
68/// for exit code 0. The command may differ from the backend name (e.g.,
69/// "kiro" backend uses "kiro-cli" command).
70pub 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
97/// Detects the first available backend from a priority list.
98///
99/// # Arguments
100/// * `priority` - List of backend names to check in order
101/// * `adapter_enabled` - Function that returns whether an adapter is enabled in config
102///
103/// # Returns
104/// * `Ok(backend_name)` - First available backend
105/// * `Err(NoBackendError)` - No backends available
106pub 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    // Check cache first
113    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        // Skip if adapter is disabled in config
124        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            // Cache the result (ignore if already set)
134            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    // Cache the failure too
141    let _ = DETECTED_BACKEND.set(None);
142
143    Err(NoBackendError { checked })
144}
145
146/// Detects a backend using default priority and all adapters enabled.
147pub 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        // 'echo' command should always be available
158        let result = Command::new("echo").arg("--version").output();
159        // Just verify the command runs without panic
160        assert!(result.is_ok());
161    }
162
163    #[test]
164    fn test_is_backend_available_nonexistent() {
165        // Nonexistent command should return false
166        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        // All adapters disabled should fail
174        let result = detect_backend(&["claude", "gemini"], |_| false);
175        // Should return error since all are disabled (empty checked list)
176        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        // Kiro backend uses kiro-cli as the command
197        assert_eq!(detection_command("kiro"), "kiro-cli");
198    }
199
200    #[test]
201    fn test_detection_command_others() {
202        // Other backends use their name as the command
203        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        // Test that default priority order is respected when no backends are available
212        // Use non-existent backends to ensure they all fail
213        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        // Should fail since no backends are actually available, but check the order
223        assert!(result.is_err());
224        if let Err(e) = result {
225            // Should check backends in the specified priority order
226            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        // Test that custom priority order is honored
242        let custom_priority = &["fake_gemini", "fake_claude", "fake_amp"];
243        let result = detect_backend(custom_priority, |_| true);
244
245        // Should fail since no backends are actually available, but check the order
246        assert!(result.is_err());
247        if let Err(e) = result {
248            // Should check backends in custom priority order
249            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        // Test that disabled adapters are skipped even if in priority list
256        let priority = &["fake_claude", "fake_gemini", "fake_kiro", "fake_codex"];
257        let result = detect_backend(priority, |backend| {
258            // Only enable fake_gemini and fake_codex
259            matches!(backend, "fake_gemini" | "fake_codex")
260        });
261
262        // Should fail since no backends are actually available, but check only enabled ones were checked
263        assert!(result.is_err());
264        if let Err(e) = result {
265            // Should only check enabled backends (fake_gemini, fake_codex), skipping disabled ones (fake_claude, fake_kiro)
266            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        // Test priority ordering with some adapters disabled
273        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            // Disable fake_kiro and fake_codex
282            !matches!(backend, "fake_kiro" | "fake_codex")
283        });
284
285        // Should fail since no backends are actually available, but check the filtered order
286        assert!(result.is_err());
287        if let Err(e) = result {
288            // Should check in priority order but skip disabled ones
289            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        // Test behavior with empty priority list
296        let result = detect_backend(&[], |_| true);
297
298        // Should fail with empty checked list
299        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        // Test that all disabled adapters results in empty checked list
308        let priority = &["claude", "gemini", "kiro"];
309        let result = detect_backend(priority, |_| false);
310
311        // Should fail with empty checked list since all are disabled
312        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        // Test that the first available backend in priority order is selected
321        // Mix available and unavailable backends to test priority
322        let priority = &[
323            "fake_nonexistent1",
324            "fake_nonexistent2",
325            "echo",
326            "fake_nonexistent3",
327        ];
328        let result = detect_backend(priority, |_| true);
329
330        // Should succeed and return "echo" (first available in the priority list)
331        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        // Test that detection continues through priority list until it finds an available backend
340        let priority = &["fake_nonexistent1", "fake_nonexistent2", "echo"];
341        let result = detect_backend(priority, |backend| {
342            // Disable the first fake backend, enable the rest
343            backend != "fake_nonexistent1"
344        });
345
346        // Should succeed and return "echo" (first enabled and available)
347        assert!(result.is_ok());
348        if let Ok(backend) = result {
349            assert_eq!(backend, "echo");
350        }
351    }
352}