Skip to main content

oxios_kernel/host_tools/
scanner.rs

1//! Host tool discovery — `HostToolScanner`.
2//!
3//! Generalizes `skill::requirements::has_bin` (a boolean `which <name>`) into a
4//! real inventory: enumerate CLIs across PATH and known package-manager install
5//! roots, classify each by its source (brew / cargo / npm / …), and probe its
6//! version. Cross-platform: uses Rust `PATH` traversal with `PATHEXT` resolution
7//! on Windows (the existing `which`-based check silently fails there).
8//!
9//! Testability is via the [`HostProbe`] trait: production uses [`RealProbe`],
10//! tests inject a [`crate::host_tools::FakeProbe`] (see `tests` module).
11
12use 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/// How a tool was installed / where it lives.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum ToolSource {
23    /// On `PATH` but not under a known manager root.
24    Path,
25    /// Homebrew (`/opt/homebrew/bin`, `/usr/local/bin`, linuxbrew).
26    Brew,
27    /// Cargo (`~/.cargo/bin`).
28    Cargo,
29    /// npm global (`$(npm prefix -g)/bin`).
30    Npm,
31    /// Bun (`~/.bun/bin`).
32    Bun,
33    /// Go (`$(go env GOPATH)/bin`).
34    Go,
35    /// pip/uv (`~/.local/bin`).
36    Pip,
37    /// Standalone binary, unclassified.
38    Binary,
39}
40
41impl ToolSource {
42    /// All variants in stable order (used for prefix iteration).
43    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/// A single detected host tool.
56#[derive(Debug, Clone, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct DetectedTool {
59    /// Binary name (e.g. `gh`).
60    pub name: String,
61    /// Absolute path to the executable (canonicalized — symlinks resolved).
62    pub path: String,
63    /// Best-effort version string (first line of `<bin> --version`), if any.
64    pub version: Option<String>,
65    /// Inferred install source.
66    pub source: ToolSource,
67}
68
69/// Filesystem + process abstraction the scanner talks to.
70///
71/// Splitting this out lets unit tests supply a virtual PATH/prefix set and
72/// fixture binaries without touching the real filesystem or spawning processes.
73#[async_trait]
74pub trait HostProbe: Send + Sync {
75    /// `PATH` directories, in order (already split on the OS separator).
76    fn path_dirs(&self) -> Vec<PathBuf>;
77
78    /// Known package-manager install roots as `(source, prefix)` pairs.
79    /// Prefixes are canonical (real install roots, not symlinks).
80    fn manager_prefixes(&self) -> Vec<(ToolSource, PathBuf)>;
81
82    /// Resolve `name` inside `dir` to an existing executable path.
83    /// On Windows this applies `PATHEXT` (`.exe`, `.cmd`, …). `None` if absent.
84    fn resolve(&self, dir: &Path, name: &str) -> Option<PathBuf>;
85
86    /// Canonicalize a path (follow symlinks). Falls back to `path` itself on
87    /// error — canonicalize fails for non-existent paths, but we only call it
88    /// after a successful [`resolve`](Self::resolve).
89    fn canonicalize(&self, path: &Path) -> PathBuf;
90
91    /// Probe the version of an executable. Runs `<path> --version` with a short
92    /// timeout and returns the first non-empty line, trimmed. `None` on failure.
93    async fn version(&self, path: &Path) -> Option<String>;
94}
95
96/// Cache entry for a single name scan.
97#[derive(Clone)]
98struct CacheEntry {
99    tool: Option<DetectedTool>,
100    scanned_at: Instant,
101}
102
103/// Cross-platform host CLI scanner with mtime/TTL caching.
104pub 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    /// Build a scanner backed by the real host ([`RealProbe`]) with a 60s TTL.
112    pub fn real() -> Self {
113        Self::with_probe(std::sync::Arc::new(RealProbe), Duration::from_secs(60))
114    }
115
116    /// Build a scanner with an explicit probe (tests) and TTL.
117    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    /// Clear the cache (force a fresh scan on next detect).
126    pub fn invalidate(&self) {
127        self.cache.write().clear();
128    }
129
130    /// Detect a single binary by name, using the cache when fresh.
131    pub async fn detect(&self, name: &str) -> Option<DetectedTool> {
132        // Cache hit?
133        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    /// Detect many names; cache-aware.
151    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    /// The actual scan for one name (no cache).
162    ///
163    /// Candidate directories: every `PATH` entry (source `Path`) plus every
164    /// manager prefix. `PATH` entries are searched in order first, then manager
165    /// roots — matching the shell's lookup behavior while still letting us
166    /// classify a hit by its real install root after canonicalization.
167    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    /// Classify a canonical path by the manager prefix it lives under.
193    /// Returns `None` when no prefix matches (caller falls back to its hint).
194    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
204// ─── RealProbe — production host interaction ─────────────────────────────────
205
206/// Real-filesystem probe. Expands `~`/env vars in manager prefixes lazily and
207/// resolves dynamic prefixes (`npm prefix -g`, `go env GOPATH`) only when the
208/// manager binary itself is on PATH.
209pub 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        // Windows has no brew.
235
236        out.push((ToolSource::Cargo, home.join(".cargo/bin")));
237        out.push((ToolSource::Bun, home.join(".bun/bin")));
238
239        // npm global — resolve dynamically only if npm is present.
240        if let Some(npm_bin) = RealProbe::npm_global_bin() {
241            out.push((ToolSource::Npm, npm_bin));
242        }
243
244        // pip/uv user dir
245        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        // Go GOPATH/bin
257        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            // Try PATHEXT extensions.
267            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            // Also try the bare name.
279            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    /// Resolve npm's global bin dir via `npm prefix -g`, only if `npm` is on PATH.
303    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    /// Resolve Go's bin dir via `go env GOPATH`, only if `go` is on PATH.
321    fn go_bin(home: &Path) -> Option<PathBuf> {
322        if which_sync("go").is_none() {
323            // Default GOPATH assumption.
324            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
341/// Sync `which`-equivalent for the bootstrap checks (npm/go presence). Uses the
342/// OS-appropriate lookup. Kept private: the scanner's public detection goes
343/// through `HostProbe::resolve`, not this.
344fn 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
363/// Run `<path> --version` (or `-Version` on Windows-native CLIs) with a 3s
364/// timeout and return the trimmed first non-empty line.
365async fn version_probe(path: &Path) -> Option<String> {
366    use tokio::process::Command;
367
368    let mut cmd = Command::new(path);
369    // Most CLIs accept `--version`. PowerShell-style `-Version` is handled by
370    // falling back below if `--version` fails.
371    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
388/// Extract the first non-empty, trimmed line from a `--version` output blob.
389fn 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    /// A fully in-memory probe for deterministic scanner tests.
402    struct FakeProbe {
403        paths: Vec<PathBuf>,
404        prefixes: Vec<(ToolSource, PathBuf)>,
405        /// (dir, name) -> raw resolved path.
406        files: HashMap<(PathBuf, String), PathBuf>,
407        /// raw path -> canonical path (simulate symlinks).
408        links: HashMap<PathBuf, PathBuf>,
409        /// canonical path -> version string.
410        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        // `/usr/local/bin/rg` is a symlink → canonical `/opt/homebrew/bin/rg`.
470        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        // simulate symlink: raw /usr/local/bin/rg → canonical /opt/homebrew/bin/rg
479        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        // Source should be Brew (canonical path under /opt/homebrew/bin), not Path.
485        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        // Second detect must hit cache (no files to re-read anyway) and still resolve.
502        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}