Skip to main content

git_paw/
detect.rs

1//! AI CLI detection.
2//!
3//! Scans PATH for known AI coding CLI binaries and merges with custom CLIs
4//! from the user's configuration. Provides the combined list for interactive
5//! selection or direct use.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// Known AI CLI binary names to scan for on PATH.
11const KNOWN_CLIS: &[&str] = &["claude", "codex", "gemini", "aider", "vibe", "qwen", "amp"];
12
13/// How a CLI was discovered.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CliSource {
16    /// Auto-detected on PATH.
17    Detected,
18    /// Defined in user configuration.
19    Custom,
20}
21
22impl fmt::Display for CliSource {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Detected => write!(f, "detected"),
26            Self::Custom => write!(f, "custom"),
27        }
28    }
29}
30
31/// Information about an available AI CLI.
32#[derive(Debug, Clone)]
33pub struct CliInfo {
34    /// Human-readable name for display in prompts and status output.
35    pub display_name: String,
36    /// The binary name (used for deduplication and identification).
37    pub binary_name: String,
38    /// Absolute path to the binary.
39    pub path: PathBuf,
40    /// How this CLI was discovered.
41    pub source: CliSource,
42}
43
44/// A custom CLI definition provided by the user's configuration.
45///
46/// This is the input type that the config module will supply. Defined here
47/// so that `detect.rs` has no dependency on the config module's types.
48#[derive(Debug, Clone)]
49pub struct CustomCliDef {
50    /// Identifier name (e.g., `"my-agent"`).
51    pub name: String,
52    /// Command or path to the binary (e.g., `"/usr/local/bin/my-agent"` or `"my-agent"`).
53    pub command: String,
54    /// Optional human-readable display name. Defaults to `name` if not set.
55    pub display_name: Option<String>,
56}
57
58/// Derives a display name from a binary name by capitalising the first letter.
59fn derive_display_name(binary_name: &str) -> String {
60    let mut chars = binary_name.chars();
61    match chars.next() {
62        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
63        None => String::new(),
64    }
65}
66
67/// Resolves a command string to an absolute path.
68///
69/// If the command is an absolute path and the file exists, returns it directly.
70/// Otherwise attempts a PATH lookup via `which`.
71fn resolve_command(command: &str) -> Option<PathBuf> {
72    let path = Path::new(command);
73    if path.is_absolute() && path.exists() {
74        return Some(path.to_path_buf());
75    }
76    which::which(command).ok()
77}
78
79/// Scans PATH for known AI CLI binaries.
80///
81/// Returns a [`CliInfo`] for each known binary found on PATH.
82pub fn detect_known_clis() -> Vec<CliInfo> {
83    KNOWN_CLIS
84        .iter()
85        .filter_map(|&name| {
86            which::which(name).ok().map(|path| CliInfo {
87                display_name: derive_display_name(name),
88                binary_name: name.to_string(),
89                path,
90                source: CliSource::Detected,
91            })
92        })
93        .collect()
94}
95
96/// Resolves custom CLI definitions to [`CliInfo`] entries.
97///
98/// For each custom CLI, the command is resolved as follows:
99/// 1. If the command looks like an absolute path and the file exists, use it directly.
100/// 2. Otherwise, look it up on PATH via `which`.
101///
102/// CLIs whose binary cannot be found are excluded with a warning on stderr.
103pub fn resolve_custom_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
104    custom
105        .iter()
106        .filter_map(|def| {
107            if let Some(p) = resolve_command(&def.command) {
108                let display = def
109                    .display_name
110                    .clone()
111                    .unwrap_or_else(|| derive_display_name(&def.name));
112                Some(CliInfo {
113                    display_name: display,
114                    binary_name: def.name.clone(),
115                    path: p,
116                    source: CliSource::Custom,
117                })
118            } else {
119                eprintln!(
120                    "warning: custom CLI '{}' not found at '{}', skipping",
121                    def.name, def.command
122                );
123                None
124            }
125        })
126        .collect()
127}
128
129/// Detects all available AI CLIs by combining auto-detected and custom CLIs.
130///
131/// Custom CLIs override auto-detected ones when they share the same `binary_name`.
132/// The returned list is sorted by display name.
133pub fn detect_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
134    let detected = detect_known_clis();
135    let custom_resolved = resolve_custom_clis(custom);
136
137    let mut by_name = std::collections::HashMap::new();
138    for cli in detected {
139        by_name.insert(cli.binary_name.clone(), cli);
140    }
141    for cli in custom_resolved {
142        by_name.insert(cli.binary_name.clone(), cli);
143    }
144
145    let mut result: Vec<CliInfo> = by_name.into_values().collect();
146    result.sort_by(|a, b| {
147        a.display_name
148            .to_lowercase()
149            .cmp(&b.display_name.to_lowercase())
150    });
151    result
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::fs;
158    use std::os::unix::fs::PermissionsExt;
159
160    /// Creates a temp directory with fake executable binaries for the given names.
161    /// Returns the `TempDir` (must be held alive) and its path.
162    fn fake_path_with_binaries(names: &[&str]) -> (tempfile::TempDir, PathBuf) {
163        let dir = tempfile::tempdir().expect("failed to create temp dir");
164        for name in names {
165            let bin_path = dir.path().join(name);
166            fs::write(&bin_path, "#!/bin/sh\n").expect("failed to write fake binary");
167            fs::set_permissions(&bin_path, fs::Permissions::from_mode(0o755))
168                .expect("failed to set permissions");
169        }
170        let path = dir.path().to_path_buf();
171        (dir, path)
172    }
173
174    /// Runs a closure with PATH set to only the given directory.
175    /// Restores the original PATH afterward. Tests using this must run
176    /// serially (via `serial_test`) to avoid races.
177    fn with_path<F, R>(path_dir: &Path, f: F) -> R
178    where
179        F: FnOnce() -> R,
180    {
181        let original = std::env::var("PATH").unwrap_or_default();
182        // SAFETY: tests using this helper are serialized via #[serial_test::serial]
183        // so no concurrent threads are reading PATH.
184        unsafe {
185            std::env::set_var("PATH", path_dir);
186        }
187        let result = f();
188        unsafe {
189            std::env::set_var("PATH", original);
190        }
191        result
192    }
193
194    // --- Acceptance: Auto-detects all 8 known CLIs when present ---
195
196    #[test]
197    #[serial_test::serial]
198    fn all_known_clis_detected_when_present() {
199        let all_names = ["claude", "codex", "gemini", "aider", "vibe", "qwen", "amp"];
200        let (_dir, path) = fake_path_with_binaries(&all_names);
201
202        let result = with_path(&path, detect_known_clis);
203
204        assert_eq!(result.len(), all_names.len());
205        for name in &all_names {
206            assert!(
207                result.iter().any(|c| c.binary_name == *name),
208                "expected '{name}' to be detected"
209            );
210        }
211        for cli in &result {
212            assert_eq!(cli.source, CliSource::Detected);
213            assert!(!cli.display_name.is_empty());
214            assert!(cli.path.exists());
215        }
216    }
217
218    // --- Acceptance: Returns empty vec when none found ---
219
220    #[test]
221    #[serial_test::serial]
222    fn returns_empty_when_no_known_clis_on_path() {
223        let (_dir, path) = fake_path_with_binaries(&[]);
224
225        let result = with_path(&path, detect_known_clis);
226
227        assert!(result.is_empty());
228    }
229
230    // --- Acceptance: Partial detection ---
231
232    #[test]
233    #[serial_test::serial]
234    fn detects_subset_of_known_clis() {
235        let (_dir, path) = fake_path_with_binaries(&["claude", "aider"]);
236
237        let result = with_path(&path, detect_known_clis);
238
239        assert_eq!(result.len(), 2);
240        assert!(result.iter().any(|c| c.binary_name == "claude"));
241        assert!(result.iter().any(|c| c.binary_name == "aider"));
242    }
243
244    // --- Acceptance: Loads custom CLIs from config and merges with detected ---
245
246    #[test]
247    #[serial_test::serial]
248    fn custom_clis_merged_with_detected() {
249        let (_dir, path) = fake_path_with_binaries(&["claude", "my-agent"]);
250        let custom = vec![CustomCliDef {
251            name: "my-agent".to_string(),
252            command: "my-agent".to_string(),
253            display_name: Some("My Agent".to_string()),
254        }];
255
256        let result = with_path(&path, || detect_clis(&custom));
257
258        assert_eq!(result.len(), 2);
259        assert!(
260            result
261                .iter()
262                .any(|c| c.binary_name == "claude" && c.source == CliSource::Detected)
263        );
264        assert!(
265            result
266                .iter()
267                .any(|c| c.binary_name == "my-agent" && c.source == CliSource::Custom)
268        );
269    }
270
271    // --- Acceptance: Excludes custom CLIs with missing binaries (with warning) ---
272
273    #[test]
274    #[serial_test::serial]
275    fn custom_cli_excluded_when_binary_missing() {
276        let (_dir, path) = fake_path_with_binaries(&[]);
277        let custom = vec![CustomCliDef {
278            name: "ghost-agent".to_string(),
279            command: "/nonexistent/ghost-agent".to_string(),
280            display_name: None,
281        }];
282
283        let result = with_path(&path, || detect_clis(&custom));
284
285        assert!(result.is_empty());
286    }
287
288    // --- Acceptance: Deduplicates — custom wins over detected ---
289
290    #[test]
291    #[serial_test::serial]
292    fn custom_cli_overrides_detected_with_same_binary_name() {
293        let (_dir, path) = fake_path_with_binaries(&["claude"]);
294        let custom = vec![CustomCliDef {
295            name: "claude".to_string(),
296            command: "claude".to_string(),
297            display_name: Some("My Custom Claude".to_string()),
298        }];
299
300        let result = with_path(&path, || detect_clis(&custom));
301
302        assert_eq!(result.len(), 1);
303        assert_eq!(result[0].binary_name, "claude");
304        assert_eq!(result[0].source, CliSource::Custom);
305        assert_eq!(result[0].display_name, "My Custom Claude");
306    }
307
308    // --- Acceptance: Each result has display_name, binary_name, path, source ---
309
310    #[test]
311    #[serial_test::serial]
312    fn detected_cli_has_all_fields() {
313        let (_dir, path) = fake_path_with_binaries(&["gemini"]);
314
315        let result = with_path(&path, detect_known_clis);
316
317        assert_eq!(result.len(), 1);
318        let cli = &result[0];
319        assert_eq!(cli.binary_name, "gemini");
320        assert_eq!(cli.display_name, "Gemini");
321        assert!(cli.path.exists());
322        assert_eq!(cli.source, CliSource::Detected);
323    }
324
325    #[test]
326    #[serial_test::serial]
327    fn custom_cli_has_all_fields() {
328        let (_dir, path) = fake_path_with_binaries(&["my-tool"]);
329        let custom = vec![CustomCliDef {
330            name: "my-tool".to_string(),
331            command: "my-tool".to_string(),
332            display_name: Some("My Tool".to_string()),
333        }];
334
335        let result = with_path(&path, || resolve_custom_clis(&custom));
336
337        assert_eq!(result.len(), 1);
338        let cli = &result[0];
339        assert_eq!(cli.binary_name, "my-tool");
340        assert_eq!(cli.display_name, "My Tool");
341        assert!(cli.path.exists());
342        assert_eq!(cli.source, CliSource::Custom);
343    }
344
345    // --- Custom CLI with absolute path ---
346
347    #[test]
348    fn custom_cli_resolved_by_absolute_path() {
349        let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
350        let abs = path.join("my-agent");
351        let custom = vec![CustomCliDef {
352            name: "my-agent".to_string(),
353            command: abs.to_string_lossy().to_string(),
354            display_name: Some("My Agent".to_string()),
355        }];
356
357        let result = resolve_custom_clis(&custom);
358
359        assert_eq!(result.len(), 1);
360        assert_eq!(result[0].path, abs);
361    }
362
363    // --- Display name defaults to capitalised binary name ---
364
365    #[test]
366    #[serial_test::serial]
367    fn custom_cli_display_name_defaults_to_capitalised_name() {
368        let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
369        let custom = vec![CustomCliDef {
370            name: "my-agent".to_string(),
371            command: "my-agent".to_string(),
372            display_name: None,
373        }];
374
375        let result = with_path(&path, || resolve_custom_clis(&custom));
376
377        assert_eq!(result[0].display_name, "My-agent");
378    }
379
380    // --- Results are sorted by display name ---
381
382    #[test]
383    #[serial_test::serial]
384    fn results_sorted_by_display_name() {
385        let (_dir, path) = fake_path_with_binaries(&["qwen", "aider", "zebra"]);
386        let custom = vec![CustomCliDef {
387            name: "zebra".to_string(),
388            command: "zebra".to_string(),
389            display_name: Some("Zebra".to_string()),
390        }];
391
392        let result = with_path(&path, || detect_clis(&custom));
393
394        let names: Vec<&str> = result.iter().map(|c| c.display_name.as_str()).collect();
395        assert_eq!(names, vec!["Aider", "Qwen", "Zebra"]);
396    }
397
398    // --- CliSource display ---
399
400    #[test]
401    fn cli_source_display_format() {
402        assert_eq!(format!("{}", CliSource::Detected), "detected");
403        assert_eq!(format!("{}", CliSource::Custom), "custom");
404    }
405}