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//!
7//! # Thread-Safe Architecture
8//!
9//! This module uses a thread-safe approach for PATH-based command resolution:
10//!
11//! 1. **No Global Environment Mutation**: Unlike the previous implementation that used
12//!    `unsafe { std::env::set_var("PATH", ...) }`, this version never modifies
13//!    the global environment.
14//!
15//! 2. **Process-Isolated PATH**: The `resolve_command_in` function and related
16//!    internal functions use `std::process::Command::env()` to set PATH only
17//!    for individual process executions. This ensures thread safety and allows
18//!    true parallel test execution.
19//!
20//! 3. **Test Parallelization**: All tests can run in parallel without
21//!    `#[serial_test::serial]` attributes because there's no shared mutable state.
22//!
23//! 4. **Robust PATH Construction**: When testing with custom PATH directories,
24//!    the implementation automatically includes system paths (`/usr/bin:/bin:/usr/local/bin`)
25//!    to ensure the `which` command itself can be found.
26
27use std::fmt;
28use std::path::{Path, PathBuf};
29
30/// Known AI CLI binary names to scan for on PATH.
31const KNOWN_CLIS: &[&str] = &[
32    "claude", "codex", "gemini", "aider", "vibe", "qwen", "amp", "opencode", "cline", "droid",
33    "pi", "junie", "cursor", "copilot", "cn", "kilo", "kimi",
34];
35
36/// How a CLI was discovered.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum CliSource {
39    /// Auto-detected on PATH.
40    Detected,
41    /// Defined in user configuration.
42    Custom,
43}
44
45impl fmt::Display for CliSource {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            Self::Detected => write!(f, "detected"),
49            Self::Custom => write!(f, "custom"),
50        }
51    }
52}
53
54/// Information about an available AI CLI.
55#[derive(Debug, Clone)]
56pub struct CliInfo {
57    /// Human-readable name for display in prompts and status output.
58    pub display_name: String,
59    /// The binary name (used for deduplication and identification).
60    pub binary_name: String,
61    /// Absolute path to the binary.
62    pub path: PathBuf,
63    /// How this CLI was discovered.
64    pub source: CliSource,
65}
66
67/// A custom CLI definition provided by the user's configuration.
68///
69/// This is the input type that the config module will supply. Defined here
70/// so that `detect.rs` has no dependency on the config module's types.
71#[derive(Debug, Clone)]
72pub struct CustomCliDef {
73    /// Identifier name (e.g., `"my-agent"`).
74    pub name: String,
75    /// Command or path to the binary (e.g., `"/usr/local/bin/my-agent"` or `"my-agent"`).
76    pub command: String,
77    /// Optional human-readable display name. Defaults to `name` if not set.
78    pub display_name: Option<String>,
79}
80
81/// Derives a display name from a binary name by capitalising the first letter.
82fn derive_display_name(binary_name: &str) -> String {
83    let mut chars = binary_name.chars();
84    match chars.next() {
85        Some(c) => c.to_uppercase().to_string() + chars.as_str(),
86        None => String::new(),
87    }
88}
89
90/// Resolves a command string to an absolute path.
91///
92/// If the command is an absolute path and the file exists, returns it directly.
93/// Otherwise attempts a PATH lookup via `which`.
94///
95/// # Architecture Note
96/// This function uses `resolve_command_in` which executes the `which` command
97/// with a custom PATH environment variable set only for that process execution.
98/// This approach is thread-safe and avoids global environment mutation, making
99/// it suitable for parallel test execution without race conditions.
100#[allow(dead_code)]
101fn resolve_command(command: &str) -> Option<PathBuf> {
102    resolve_command_in(command, std::env::var_os("PATH").as_ref())
103}
104
105fn resolve_command_in(command: &str, path: Option<&std::ffi::OsString>) -> Option<PathBuf> {
106    let path_obj = Path::new(command);
107    if path_obj.is_absolute() && path_obj.exists() {
108        return Some(path_obj.to_path_buf());
109    }
110
111    // Use Command with custom PATH instead of modifying global environment
112    let mut cmd = std::process::Command::new("which");
113    cmd.arg(command);
114
115    // Build the PATH to use: custom path + system paths to ensure 'which' itself is found
116    let final_path = if let Some(path_str) = path {
117        // Convert OsString to String for manipulation
118        let path_string = path_str.to_string_lossy().into_owned();
119        // Include system paths to ensure 'which' and other binaries are found
120        format!("{path_string}:/usr/bin:/bin:/usr/local/bin")
121    } else {
122        // Use system PATH if no custom path provided
123        "/usr/bin:/bin:/usr/local/bin".to_string()
124    };
125
126    cmd.env("PATH", final_path);
127
128    match cmd.output() {
129        Ok(output) if output.status.success() => {
130            let path_str = String::from_utf8_lossy(&output.stdout);
131            let path_str = path_str.trim();
132            if !path_str.is_empty() {
133                return Some(PathBuf::from(path_str));
134            }
135        }
136        _ => {}
137    }
138
139    None
140}
141
142/// Scans PATH for known AI CLI binaries.
143///
144/// Returns a [`CliInfo`] for each known binary found on PATH.
145pub fn detect_known_clis() -> Vec<CliInfo> {
146    detect_known_clis_in(std::env::var_os("PATH").as_ref())
147}
148
149fn detect_known_clis_in(path: Option<&std::ffi::OsString>) -> Vec<CliInfo> {
150    KNOWN_CLIS
151        .iter()
152        .filter_map(|&name| {
153            resolve_command_in(name, path).map(|path| CliInfo {
154                display_name: derive_display_name(name),
155                binary_name: name.to_string(),
156                path,
157                source: CliSource::Detected,
158            })
159        })
160        .collect()
161}
162
163/// Resolves custom CLI definitions to [`CliInfo`] entries.
164///
165/// For each custom CLI, the command is resolved as follows:
166/// 1. If the command looks like an absolute path and the file exists, use it directly.
167/// 2. Otherwise, look it up on PATH via `which`.
168///
169/// CLIs whose binary cannot be found are excluded with a warning on stderr.
170pub fn resolve_custom_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
171    resolve_custom_clis_in(custom, std::env::var_os("PATH").as_ref())
172}
173
174fn resolve_custom_clis_in(
175    custom: &[CustomCliDef],
176    path: Option<&std::ffi::OsString>,
177) -> Vec<CliInfo> {
178    custom
179        .iter()
180        .filter_map(|def| {
181            if let Some(p) = resolve_command_in(&def.command, path) {
182                let display = def
183                    .display_name
184                    .clone()
185                    .unwrap_or_else(|| derive_display_name(&def.name));
186                Some(CliInfo {
187                    display_name: display,
188                    binary_name: def.name.clone(),
189                    path: p,
190                    source: CliSource::Custom,
191                })
192            } else {
193                eprintln!(
194                    "warning: custom CLI '{}' not found at '{}', skipping",
195                    def.name, def.command
196                );
197                None
198            }
199        })
200        .collect()
201}
202
203/// Detects all available AI CLIs by combining auto-detected and custom CLIs.
204///
205/// Custom CLIs override auto-detected ones when they share the same `binary_name`.
206/// The returned list is sorted by display name.
207pub fn detect_clis(custom: &[CustomCliDef]) -> Vec<CliInfo> {
208    detect_clis_in(custom, std::env::var_os("PATH").as_ref())
209}
210
211fn detect_clis_in(custom: &[CustomCliDef], path: Option<&std::ffi::OsString>) -> Vec<CliInfo> {
212    let detected = detect_known_clis_in(path);
213    let custom_resolved = resolve_custom_clis_in(custom, path);
214
215    let mut by_name = std::collections::HashMap::new();
216    for cli in detected {
217        by_name.insert(cli.binary_name.clone(), cli);
218    }
219    for cli in custom_resolved {
220        by_name.insert(cli.binary_name.clone(), cli);
221    }
222
223    let mut result: Vec<CliInfo> = by_name.into_values().collect();
224    result.sort_by(|a, b| {
225        a.display_name
226            .to_lowercase()
227            .cmp(&b.display_name.to_lowercase())
228    });
229    result
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::fs;
236    use std::os::unix::fs::PermissionsExt;
237
238    /// Creates a temp directory with fake executable binaries for the given names.
239    /// Returns the `TempDir` (must be held alive) and its path.
240    fn fake_path_with_binaries(names: &[&str]) -> (tempfile::TempDir, PathBuf) {
241        let dir = tempfile::tempdir().expect("failed to create temp dir");
242        for name in names {
243            let bin_path = dir.path().join(name);
244            fs::write(&bin_path, "#!/bin/sh\n").expect("failed to write fake binary");
245            fs::set_permissions(&bin_path, fs::Permissions::from_mode(0o755))
246                .expect("failed to set permissions");
247        }
248        let path = dir.path().to_path_buf();
249        (dir, path)
250    }
251
252    // --- Acceptance: Auto-detects all 8 known CLIs when present ---
253
254    #[test]
255    fn all_known_clis_detected_when_present() {
256        let all_names = [
257            "claude", "codex", "gemini", "aider", "vibe", "qwen", "amp", "opencode", "cline",
258            "droid", "pi", "junie", "cursor", "copilot", "cn", "kilo", "kimi",
259        ];
260        let (_dir, path) = fake_path_with_binaries(&all_names);
261
262        let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
263
264        assert_eq!(result.len(), all_names.len());
265        for name in &all_names {
266            assert!(
267                result.iter().any(|c| c.binary_name == *name),
268                "expected '{name}' to be detected"
269            );
270        }
271        for cli in &result {
272            assert_eq!(cli.source, CliSource::Detected);
273            assert!(!cli.display_name.is_empty());
274            assert!(cli.path.exists());
275        }
276    }
277
278    // --- Acceptance: Returns empty vec when none found ---
279
280    #[test]
281    fn returns_empty_when_no_known_clis_on_path() {
282        let (_dir, path) = fake_path_with_binaries(&[]);
283
284        let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
285
286        assert!(result.is_empty());
287    }
288
289    // --- Acceptance: Partial detection ---
290
291    #[test]
292    fn detects_subset_of_known_clis() {
293        let (_dir, path) = fake_path_with_binaries(&["claude", "aider"]);
294
295        let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
296
297        assert_eq!(result.len(), 2);
298        assert!(result.iter().any(|c| c.binary_name == "claude"));
299        assert!(result.iter().any(|c| c.binary_name == "aider"));
300    }
301
302    // --- Acceptance: Loads custom CLIs from config and merges with detected ---
303
304    #[test]
305    fn custom_clis_merged_with_detected() {
306        let (_dir, path) = fake_path_with_binaries(&["claude", "my-agent"]);
307        let custom = vec![CustomCliDef {
308            name: "my-agent".to_string(),
309            command: "my-agent".to_string(),
310            display_name: Some("My Agent".to_string()),
311        }];
312
313        let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
314
315        assert_eq!(result.len(), 2);
316        assert!(
317            result
318                .iter()
319                .any(|c| c.binary_name == "claude" && c.source == CliSource::Detected)
320        );
321        assert!(
322            result
323                .iter()
324                .any(|c| c.binary_name == "my-agent" && c.source == CliSource::Custom)
325        );
326    }
327
328    // --- Acceptance: Excludes custom CLIs with missing binaries (with warning) ---
329
330    #[test]
331    fn custom_cli_excluded_when_binary_missing() {
332        let (_dir, path) = fake_path_with_binaries(&[]);
333        let custom = vec![CustomCliDef {
334            name: "ghost-agent".to_string(),
335            command: "/nonexistent/ghost-agent".to_string(),
336            display_name: None,
337        }];
338
339        let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
340
341        assert!(result.is_empty());
342    }
343
344    // --- Acceptance: Deduplicates — custom wins over detected ---
345
346    #[test]
347    fn custom_cli_overrides_detected_with_same_binary_name() {
348        let (_dir, path) = fake_path_with_binaries(&["claude"]);
349        let custom = vec![CustomCliDef {
350            name: "claude".to_string(),
351            command: "claude".to_string(),
352            display_name: Some("My Custom Claude".to_string()),
353        }];
354
355        let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
356
357        assert_eq!(result.len(), 1);
358        assert_eq!(result[0].binary_name, "claude");
359        assert_eq!(result[0].source, CliSource::Custom);
360        assert_eq!(result[0].display_name, "My Custom Claude");
361    }
362
363    // --- Acceptance: Each result has display_name, binary_name, path, source ---
364
365    #[test]
366    fn detected_cli_has_all_fields() {
367        let (_dir, path) = fake_path_with_binaries(&["gemini"]);
368
369        let result = detect_known_clis_in(Some(&path.as_os_str().to_os_string()));
370
371        assert_eq!(result.len(), 1);
372        let cli = &result[0];
373        assert_eq!(cli.binary_name, "gemini");
374        assert_eq!(cli.display_name, "Gemini");
375        assert!(cli.path.exists());
376        assert_eq!(cli.source, CliSource::Detected);
377    }
378
379    #[test]
380    fn custom_cli_has_all_fields() {
381        let (_dir, path) = fake_path_with_binaries(&["my-tool"]);
382        let custom = vec![CustomCliDef {
383            name: "my-tool".to_string(),
384            command: "my-tool".to_string(),
385            display_name: Some("My Tool".to_string()),
386        }];
387
388        let result = resolve_custom_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
389
390        assert_eq!(result.len(), 1);
391        let cli = &result[0];
392        assert_eq!(cli.binary_name, "my-tool");
393        assert_eq!(cli.display_name, "My Tool");
394        assert!(cli.path.exists());
395        assert_eq!(cli.source, CliSource::Custom);
396    }
397
398    // --- Custom CLI with absolute path ---
399
400    #[test]
401    fn custom_cli_resolved_by_absolute_path() {
402        let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
403        let abs = path.join("my-agent");
404        let custom = vec![CustomCliDef {
405            name: "my-agent".to_string(),
406            command: abs.to_string_lossy().to_string(),
407            display_name: Some("My Agent".to_string()),
408        }];
409
410        let result = resolve_custom_clis(&custom);
411
412        assert_eq!(result.len(), 1);
413        assert_eq!(result[0].path, abs);
414    }
415
416    // --- Display name defaults to capitalised binary name ---
417
418    #[test]
419    fn custom_cli_display_name_defaults_to_capitalised_name() {
420        let (_dir, path) = fake_path_with_binaries(&["my-agent"]);
421        let custom = vec![CustomCliDef {
422            name: "my-agent".to_string(),
423            command: "my-agent".to_string(),
424            display_name: None,
425        }];
426
427        let result = resolve_custom_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
428
429        assert_eq!(result[0].display_name, "My-agent");
430    }
431
432    // --- Results are sorted by display name ---
433
434    #[test]
435    fn results_sorted_by_display_name() {
436        let (_dir, path) = fake_path_with_binaries(&["qwen", "aider", "zebra"]);
437        let custom = vec![CustomCliDef {
438            name: "zebra".to_string(),
439            command: "zebra".to_string(),
440            display_name: Some("Zebra".to_string()),
441        }];
442
443        let result = detect_clis_in(&custom, Some(&path.as_os_str().to_os_string()));
444
445        let names: Vec<&str> = result.iter().map(|c| c.display_name.as_str()).collect();
446        assert_eq!(names, vec!["Aider", "Qwen", "Zebra"]);
447    }
448
449    // --- CliSource display ---
450
451    #[test]
452    fn cli_source_display_format() {
453        assert_eq!(format!("{}", CliSource::Detected), "detected");
454        assert_eq!(format!("{}", CliSource::Custom), "custom");
455    }
456}