1use std::fmt;
8use std::path::{Path, PathBuf};
9
10const KNOWN_CLIS: &[&str] = &["claude", "codex", "gemini", "aider", "vibe", "qwen", "amp"];
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum CliSource {
16 Detected,
18 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#[derive(Debug, Clone)]
33pub struct CliInfo {
34 pub display_name: String,
36 pub binary_name: String,
38 pub path: PathBuf,
40 pub source: CliSource,
42}
43
44#[derive(Debug, Clone)]
49pub struct CustomCliDef {
50 pub name: String,
52 pub command: String,
54 pub display_name: Option<String>,
56}
57
58fn 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
67fn 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
79pub 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
96pub 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
129pub 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
401 fn cli_source_display_format() {
402 assert_eq!(format!("{}", CliSource::Detected), "detected");
403 assert_eq!(format!("{}", CliSource::Custom), "custom");
404 }
405}