1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15
16use async_trait::async_trait;
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum ToolSource {
23 Path,
25 Brew,
27 Cargo,
29 Npm,
31 Bun,
33 Go,
35 Pip,
37 Binary,
39}
40
41impl ToolSource {
42 pub const ALL: &'static [ToolSource] = &[
44 ToolSource::Brew,
45 ToolSource::Cargo,
46 ToolSource::Npm,
47 ToolSource::Bun,
48 ToolSource::Go,
49 ToolSource::Pip,
50 ToolSource::Path,
51 ToolSource::Binary,
52 ];
53}
54
55#[derive(Debug, Clone, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct DetectedTool {
59 pub name: String,
61 pub path: String,
63 pub version: Option<String>,
65 pub source: ToolSource,
67}
68
69#[async_trait]
74pub trait HostProbe: Send + Sync {
75 fn path_dirs(&self) -> Vec<PathBuf>;
77
78 fn manager_prefixes(&self) -> Vec<(ToolSource, PathBuf)>;
81
82 fn resolve(&self, dir: &Path, name: &str) -> Option<PathBuf>;
85
86 fn canonicalize(&self, path: &Path) -> PathBuf;
90
91 async fn version(&self, path: &Path) -> Option<String>;
94}
95
96#[derive(Clone)]
98struct CacheEntry {
99 tool: Option<DetectedTool>,
100 scanned_at: Instant,
101}
102
103pub struct HostToolScanner {
105 probe: std::sync::Arc<dyn HostProbe>,
106 cache: parking_lot::RwLock<HashMap<String, CacheEntry>>,
107 ttl: Duration,
108}
109
110impl HostToolScanner {
111 pub fn real() -> Self {
113 Self::with_probe(std::sync::Arc::new(RealProbe), Duration::from_secs(60))
114 }
115
116 pub fn with_probe(probe: std::sync::Arc<dyn HostProbe>, ttl: Duration) -> Self {
118 Self {
119 probe,
120 cache: parking_lot::RwLock::new(HashMap::new()),
121 ttl,
122 }
123 }
124
125 pub fn invalidate(&self) {
127 self.cache.write().clear();
128 }
129
130 pub async fn detect(&self, name: &str) -> Option<DetectedTool> {
132 if let Some(entry) = self.cache.read().get(name)
134 && entry.scanned_at.elapsed() < self.ttl
135 {
136 return entry.tool.clone();
137 }
138
139 let tool = self.scan_uncached(name).await;
140 self.cache.write().insert(
141 name.to_string(),
142 CacheEntry {
143 tool: tool.clone(),
144 scanned_at: Instant::now(),
145 },
146 );
147 tool
148 }
149
150 pub async fn detect_many(&self, names: &[String]) -> Vec<DetectedTool> {
152 let mut out = Vec::with_capacity(names.len());
153 for name in names {
154 if let Some(t) = self.detect(name).await {
155 out.push(t);
156 }
157 }
158 out
159 }
160
161 async fn scan_uncached(&self, name: &str) -> Option<DetectedTool> {
168 let mut candidates: Vec<(ToolSource, PathBuf)> = self
169 .probe
170 .path_dirs()
171 .into_iter()
172 .map(|d| (ToolSource::Path, d))
173 .collect();
174 candidates.extend(self.probe.manager_prefixes());
175
176 for (source_hint, dir) in &candidates {
177 if let Some(raw) = self.probe.resolve(dir, name) {
178 let canon = self.probe.canonicalize(&raw);
179 let resolved_source = self.classify(&canon).unwrap_or(*source_hint);
180 let version = self.probe.version(&canon).await;
181 return Some(DetectedTool {
182 name: name.to_string(),
183 path: canon.to_string_lossy().into_owned(),
184 version,
185 source: resolved_source,
186 });
187 }
188 }
189 None
190 }
191
192 fn classify(&self, canon: &Path) -> Option<ToolSource> {
195 for (source, prefix) in self.probe.manager_prefixes() {
196 if canon.starts_with(prefix) {
197 return Some(source);
198 }
199 }
200 None
201 }
202}
203
204pub struct RealProbe;
210
211#[async_trait]
212impl HostProbe for RealProbe {
213 fn path_dirs(&self) -> Vec<PathBuf> {
214 let sep = if cfg!(windows) { ';' } else { ':' };
215 std::env::var("PATH")
216 .map(|p| p.split(sep).map(PathBuf::from).collect())
217 .unwrap_or_default()
218 }
219
220 fn manager_prefixes(&self) -> Vec<(ToolSource, PathBuf)> {
221 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
222 let mut out: Vec<(ToolSource, PathBuf)> = Vec::new();
223
224 if cfg!(target_os = "macos") {
225 out.push((ToolSource::Brew, PathBuf::from("/opt/homebrew/bin")));
226 out.push((ToolSource::Brew, PathBuf::from("/usr/local/bin")));
227 } else if cfg!(target_os = "linux") {
228 out.push((
229 ToolSource::Brew,
230 PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
231 ));
232 out.push((ToolSource::Brew, home.join(".linuxbrew/bin")));
233 }
234 out.push((ToolSource::Cargo, home.join(".cargo/bin")));
237 out.push((ToolSource::Bun, home.join(".bun/bin")));
238
239 if let Some(npm_bin) = RealProbe::npm_global_bin() {
241 out.push((ToolSource::Npm, npm_bin));
242 }
243
244 if cfg!(windows) {
246 if let Ok(appdata) = std::env::var("APPDATA") {
247 out.push((
248 ToolSource::Pip,
249 PathBuf::from(appdata).join("Python/Scripts"),
250 ));
251 }
252 } else {
253 out.push((ToolSource::Pip, home.join(".local/bin")));
254 }
255
256 if let Some(go_bin) = RealProbe::go_bin(&home) {
258 out.push((ToolSource::Go, go_bin));
259 }
260
261 out
262 }
263
264 fn resolve(&self, dir: &Path, name: &str) -> Option<PathBuf> {
265 if cfg!(windows) {
266 let exts: Vec<String> = std::env::var("PATHEXT")
268 .unwrap_or_else(|_| ".EXE;.CMD;.BAT".into())
269 .split(';')
270 .map(|s| s.to_string())
271 .collect();
272 for ext in &exts {
273 let candidate = dir.join(format!("{name}{ext}"));
274 if candidate.is_file() {
275 return Some(candidate);
276 }
277 }
278 let bare = dir.join(name);
280 if bare.is_file() {
281 return Some(bare);
282 }
283 } else {
284 let candidate = dir.join(name);
285 if candidate.is_file() {
286 return Some(candidate);
287 }
288 }
289 None
290 }
291
292 fn canonicalize(&self, path: &Path) -> PathBuf {
293 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
294 }
295
296 async fn version(&self, path: &Path) -> Option<String> {
297 version_probe(path).await
298 }
299}
300
301impl RealProbe {
302 fn npm_global_bin() -> Option<PathBuf> {
304 which_sync("npm")?;
305 let out = std::process::Command::new("npm")
306 .arg("prefix")
307 .arg("-g")
308 .output()
309 .ok()?;
310 if !out.status.success() {
311 return None;
312 }
313 let prefix = String::from_utf8_lossy(&out.stdout).trim().to_string();
314 if prefix.is_empty() {
315 return None;
316 }
317 Some(PathBuf::from(prefix).join(if cfg!(windows) { "" } else { "bin" }))
318 }
319
320 fn go_bin(home: &Path) -> Option<PathBuf> {
322 if which_sync("go").is_none() {
323 return Some(home.join("go/bin"));
325 }
326 let out = std::process::Command::new("go")
327 .args(["env", "GOPATH"])
328 .output()
329 .ok()?;
330 if !out.status.success() {
331 return None;
332 }
333 let gopath = String::from_utf8_lossy(&out.stdout).trim().to_string();
334 if gopath.is_empty() {
335 return None;
336 }
337 Some(PathBuf::from(gopath).join("bin"))
338 }
339}
340
341fn which_sync(name: &str) -> Option<PathBuf> {
345 let sep = if cfg!(windows) { ';' } else { ':' };
346 let path = std::env::var("PATH").ok()?;
347 for dir in path.split(sep) {
348 let p = PathBuf::from(dir);
349 if cfg!(windows) {
350 for ext in ["exe", "cmd", "bat"] {
351 let c = p.join(format!("{name}.{ext}"));
352 if c.is_file() {
353 return Some(c);
354 }
355 }
356 } else if p.join(name).is_file() {
357 return Some(p.join(name));
358 }
359 }
360 None
361}
362
363async fn version_probe(path: &Path) -> Option<String> {
366 use tokio::process::Command;
367
368 let mut cmd = Command::new(path);
369 cmd.arg("--version");
372 cmd.stdout(std::process::Stdio::piped());
373 cmd.stderr(std::process::Stdio::null());
374
375 let fut = async {
376 let out = cmd.output().await.ok()?;
377 if !out.status.success() {
378 return None;
379 }
380 first_version_line(&String::from_utf8_lossy(&out.stdout))
381 };
382
383 tokio::time::timeout(Duration::from_secs(3), fut)
384 .await
385 .unwrap_or_default()
386}
387
388fn first_version_line(stdout: &str) -> Option<String> {
390 stdout
391 .lines()
392 .map(|l| l.trim())
393 .find(|l| !l.is_empty())
394 .map(|l| l.to_string())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 struct FakeProbe {
403 paths: Vec<PathBuf>,
404 prefixes: Vec<(ToolSource, PathBuf)>,
405 files: HashMap<(PathBuf, String), PathBuf>,
407 links: HashMap<PathBuf, PathBuf>,
409 versions: HashMap<PathBuf, String>,
411 }
412
413 impl FakeProbe {
414 fn new() -> Self {
415 Self {
416 paths: vec![],
417 prefixes: vec![],
418 files: HashMap::new(),
419 links: HashMap::new(),
420 versions: HashMap::new(),
421 }
422 }
423 }
424
425 #[async_trait]
426 impl HostProbe for FakeProbe {
427 fn path_dirs(&self) -> Vec<PathBuf> {
428 self.paths.clone()
429 }
430 fn manager_prefixes(&self) -> Vec<(ToolSource, PathBuf)> {
431 self.prefixes.clone()
432 }
433 fn resolve(&self, dir: &Path, name: &str) -> Option<PathBuf> {
434 self.files
435 .get(&(dir.to_path_buf(), name.to_string()))
436 .cloned()
437 }
438 fn canonicalize(&self, path: &Path) -> PathBuf {
439 self.links
440 .get(path)
441 .cloned()
442 .unwrap_or_else(|| path.to_path_buf())
443 }
444 async fn version(&self, path: &Path) -> Option<String> {
445 self.versions.get(path).cloned()
446 }
447 }
448
449 #[tokio::test]
450 async fn detects_tool_on_path() {
451 let dir = PathBuf::from("/usr/local/bin");
452 let mut probe = FakeProbe::new();
453 probe.paths = vec![dir.clone()];
454 probe
455 .files
456 .insert((dir.clone(), "rg".into()), dir.join("rg"));
457 probe.versions.insert(dir.join("rg"), "ripgrep 14.0".into());
458 let scanner =
459 HostToolScanner::with_probe(std::sync::Arc::new(probe), Duration::from_secs(60));
460
461 let t = scanner.detect("rg").await.expect("rg should be found");
462 assert_eq!(t.name, "rg");
463 assert_eq!(t.source, ToolSource::Path);
464 assert_eq!(t.version.as_deref(), Some("ripgrep 14.0"));
465 }
466
467 #[tokio::test]
468 async fn classifies_brew_via_symlink_canonicalize() {
469 let mut probe = FakeProbe::new();
471 let link_dir = PathBuf::from("/usr/local/bin");
472 let real_dir = PathBuf::from("/opt/homebrew/bin");
473 probe.paths = vec![link_dir.clone()];
474 probe.prefixes = vec![(ToolSource::Brew, real_dir.clone())];
475 probe
476 .files
477 .insert((link_dir.clone(), "rg".into()), link_dir.join("rg"));
478 probe.links.insert(link_dir.join("rg"), real_dir.join("rg"));
480 let scanner =
481 HostToolScanner::with_probe(std::sync::Arc::new(probe), Duration::from_secs(60));
482
483 let t = scanner.detect("rg").await.unwrap();
484 assert_eq!(t.source, ToolSource::Brew);
486 assert_eq!(t.path, "/opt/homebrew/bin/rg");
487 }
488
489 #[tokio::test]
490 async fn cache_serves_repeat_lookups() {
491 let mut probe = FakeProbe::new();
492 let dir = PathBuf::from("/bin");
493 probe.paths = vec![dir.clone()];
494 probe
495 .files
496 .insert((dir.clone(), "ls".into()), dir.join("ls"));
497 let scanner =
498 HostToolScanner::with_probe(std::sync::Arc::new(probe), Duration::from_secs(60));
499
500 let _ = scanner.detect("ls").await;
501 let t = scanner.detect("ls").await.unwrap();
503 assert_eq!(t.name, "ls");
504 }
505
506 #[tokio::test]
507 async fn invalidate_forces_rescan() {
508 let mut probe = FakeProbe::new();
509 let dir = PathBuf::from("/bin");
510 probe.paths = vec![dir.clone()];
511 probe
512 .files
513 .insert((dir.clone(), "ls".into()), dir.join("ls"));
514 let scanner =
515 HostToolScanner::with_probe(std::sync::Arc::new(probe), Duration::from_secs(60));
516
517 let _ = scanner.detect("ls").await;
518 scanner.invalidate();
519 assert!(scanner.cache.read().is_empty());
520 }
521
522 #[tokio::test]
523 async fn missing_binary_returns_none() {
524 let mut probe = FakeProbe::new();
525 probe.paths = vec![PathBuf::from("/bin")];
526 let scanner =
527 HostToolScanner::with_probe(std::sync::Arc::new(probe), Duration::from_secs(60));
528 assert!(scanner.detect("does-not-exist").await.is_none());
529 }
530
531 #[test]
532 fn first_version_line_skips_blank() {
533 assert_eq!(
534 first_version_line("\n\n ripgrep 14.0\nbuild foo"),
535 Some("ripgrep 14.0".into())
536 );
537 assert_eq!(first_version_line(""), None);
538 }
539}