Skip to main content

bynk/
probe.rs

1//! The shared detection probe: **presence + version + provenance**.
2//!
3//! This generalises `bynkc`'s old `tool_exists` (which shelled the POSIX
4//! `which` and conceded it was Unix-only). Detection is backed by the `which`
5//! crate, which handles `PATHEXT`/`where` on Windows, so a *user-facing*
6//! environment check tells the truth on every platform (v0.46 portability
7//! decision).
8//!
9//! Lookups go through a [`Toolbox`] so the capability/exit matrix and the
10//! output goldens can run against a deterministic fake instead of the host.
11
12use std::ffi::OsStr;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16/// A parsed tool version. Missing components default to zero, so `"v18"` parses
17/// as `18.0.0` and compares sensibly against a major-version floor.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub struct Version {
20    pub major: u32,
21    pub minor: u32,
22    pub patch: u32,
23}
24
25impl Version {
26    /// Extract the first dotted-decimal run from arbitrary `--version` output
27    /// (e.g. `"v18.17.0"`, `"Version 5.4.2"`, `"⛅️ wrangler 3.90.0"`).
28    pub fn parse(s: &str) -> Option<Version> {
29        let b = s.as_bytes();
30        let mut i = 0;
31        while i < b.len() && !b[i].is_ascii_digit() {
32            i += 1;
33        }
34        if i >= b.len() {
35            return None;
36        }
37        let mut nums = [0u32; 3];
38        let mut slot = 0;
39        while i < b.len() && slot < 3 {
40            let start = i;
41            while i < b.len() && b[i].is_ascii_digit() {
42                i += 1;
43            }
44            nums[slot] = s[start..i].parse().ok()?;
45            slot += 1;
46            if i < b.len() && b[i] == b'.' {
47                i += 1;
48            } else {
49                break;
50            }
51        }
52        Some(Version {
53            major: nums[0],
54            minor: nums[1],
55            patch: nums[2],
56        })
57    }
58}
59
60impl std::fmt::Display for Version {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
63    }
64}
65
66/// Where a tool was found — the **provenance** half of a probe. The distinction
67/// between [`Provenance::Path`]/[`Provenance::ProjectLocal`] (installed) and
68/// [`Provenance::Npx`] (fetchable on demand) is the whole point: `npx --yes`
69/// will *download* a package at first use, so it must never read as a green
70/// "ok".
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Provenance {
73    /// On the global `PATH`, at this resolved path.
74    Path(PathBuf),
75    /// In a project-local `node_modules/.bin` (preferred over `PATH`).
76    ProjectLocal(PathBuf),
77    /// Not installed, but **provisionable** via `npx --yes <tool>` — a deferred
78    /// download, not a present tool.
79    Npx,
80    /// Not found, and not provisionable.
81    Missing,
82}
83
84impl Provenance {
85    /// The stable token used in `--format json`/`short` and the human table.
86    pub fn token(&self) -> &'static str {
87        match self {
88            Provenance::Path(_) => "path",
89            Provenance::ProjectLocal(_) => "project-local",
90            Provenance::Npx => "npx",
91            Provenance::Missing => "missing",
92        }
93    }
94
95    /// The resolved path, if the tool is actually installed.
96    pub fn path(&self) -> Option<&Path> {
97        match self {
98            Provenance::Path(p) | Provenance::ProjectLocal(p) => Some(p),
99            _ => None,
100        }
101    }
102}
103
104/// One detection result: a tool, its version (if installed and it reports one),
105/// and where it came from.
106#[derive(Debug, Clone)]
107pub struct Probe {
108    pub tool: String,
109    pub version: Option<Version>,
110    pub provenance: Provenance,
111}
112
113impl Probe {
114    /// Installed on disk (`PATH` or project-local) — not merely provisionable.
115    pub fn is_present(&self) -> bool {
116        matches!(
117            self.provenance,
118            Provenance::Path(_) | Provenance::ProjectLocal(_)
119        )
120    }
121
122    /// Absent but fetchable on demand via `npx`.
123    pub fn is_provisionable(&self) -> bool {
124        matches!(self.provenance, Provenance::Npx)
125    }
126
127    /// Neither installed nor provisionable.
128    pub fn is_missing(&self) -> bool {
129        matches!(self.provenance, Provenance::Missing)
130    }
131}
132
133/// How a detection should look: where to search, and whether `npx`
134/// fetch-on-demand counts as a (provisionable) fallback. `node` itself is never
135/// `allow_npx` — you cannot `npx` a runtime.
136#[derive(Debug, Clone, Copy, Default)]
137pub struct DetectOpts<'a> {
138    pub project_root: Option<&'a Path>,
139    pub allow_npx: bool,
140}
141
142/// Abstraction over the host so detection is testable. The real implementation
143/// is [`SystemToolbox`]; tests supply a deterministic fake.
144pub trait Toolbox {
145    /// Resolve `tool` on the global `PATH` (with `PATHEXT` semantics).
146    fn on_path(&self, tool: &str) -> Option<PathBuf>;
147    /// Resolve `tool` inside a specific directory (a `node_modules/.bin`).
148    fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf>;
149    /// Run `<path> --version` and parse the first version it prints.
150    fn version(&self, path: &Path) -> Option<Version>;
151    /// Is `npx` itself available to provision packages on demand?
152    fn npx_available(&self) -> bool;
153}
154
155/// Detect a single tool: project-local first (it wins over a global install),
156/// then `PATH`, then — only if `allow_npx` — an `npx` provisionable fallback,
157/// else missing.
158pub fn detect(tb: &dyn Toolbox, tool: &str, opts: DetectOpts<'_>) -> Probe {
159    if let Some(root) = opts.project_root {
160        let bin = root.join("node_modules").join(".bin");
161        if let Some(p) = tb.in_dir(&bin, tool) {
162            let version = tb.version(&p);
163            return Probe {
164                tool: tool.to_string(),
165                version,
166                provenance: Provenance::ProjectLocal(p),
167            };
168        }
169    }
170    if let Some(p) = tb.on_path(tool) {
171        let version = tb.version(&p);
172        return Probe {
173            tool: tool.to_string(),
174            version,
175            provenance: Provenance::Path(p),
176        };
177    }
178    if opts.allow_npx && tb.npx_available() {
179        return Probe {
180            tool: tool.to_string(),
181            version: None,
182            provenance: Provenance::Npx,
183        };
184    }
185    Probe {
186        tool: tool.to_string(),
187        version: None,
188        provenance: Provenance::Missing,
189    }
190}
191
192/// The real host: `which`-crate lookups and a `--version` shell-out.
193#[derive(Debug, Default, Clone, Copy)]
194pub struct SystemToolbox;
195
196impl Toolbox for SystemToolbox {
197    fn on_path(&self, tool: &str) -> Option<PathBuf> {
198        which::which(tool).ok()
199    }
200
201    fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
202        // `which_in` applies the same PATHEXT/extension resolution inside the
203        // given directory, so a Windows `tsc.cmd`/`tsc.exe` resolves too.
204        which::which_in(tool, Some(dir.as_os_str()), dir).ok()
205    }
206
207    fn version(&self, path: &Path) -> Option<Version> {
208        let out = Command::new(path).arg("--version").output().ok()?;
209        let stdout = String::from_utf8_lossy(&out.stdout);
210        let parsed = Version::parse(&stdout);
211        if parsed.is_some() {
212            return parsed;
213        }
214        // Some tools print their version banner to stderr.
215        Version::parse(&String::from_utf8_lossy(&out.stderr))
216    }
217
218    fn npx_available(&self) -> bool {
219        which::which(OsStr::new("npx")).is_ok()
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226
227    #[test]
228    fn parses_common_version_banners() {
229        assert_eq!(
230            Version::parse("v18.17.0"),
231            Some(Version {
232                major: 18,
233                minor: 17,
234                patch: 0
235            })
236        );
237        assert_eq!(
238            Version::parse("Version 5.4.2"),
239            Some(Version {
240                major: 5,
241                minor: 4,
242                patch: 2
243            })
244        );
245        assert_eq!(
246            Version::parse("⛅️ wrangler 3.90.0"),
247            Some(Version {
248                major: 3,
249                minor: 90,
250                patch: 0
251            })
252        );
253        assert_eq!(
254            Version::parse("v20"),
255            Some(Version {
256                major: 20,
257                minor: 0,
258                patch: 0
259            })
260        );
261        assert_eq!(Version::parse("no digits here"), None);
262    }
263}