bynk_driver/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/// #1758: the program to spawn for `tool`: the path `which` resolves on
193/// `PATH`, or the bare name when it resolves nothing. Spawn tools through this
194/// rather than `Command::new("<name>")`. On Windows, `Command` looks up a bare
195/// name with `.exe` only, so an npm shim (`npx.cmd`, `tsc.cmd`) that detection
196/// finds through `PATHEXT` is never started. An explicit path to a `.cmd` is
197/// spawned correctly, with its arguments escaped.
198///
199/// Resolved through [`SystemToolbox::on_path`], the same lookup detection uses,
200/// so the spawned program is the one a probe of `tool` reports.
201pub fn program_path(tool: &str) -> PathBuf {
202 SystemToolbox
203 .on_path(tool)
204 .unwrap_or_else(|| PathBuf::from(tool))
205}
206
207/// The real host: `which`-crate lookups and a `--version` shell-out.
208#[derive(Debug, Default, Clone, Copy)]
209pub struct SystemToolbox;
210
211impl Toolbox for SystemToolbox {
212 fn on_path(&self, tool: &str) -> Option<PathBuf> {
213 which::which(tool).ok()
214 }
215
216 fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
217 // `which_in` applies the same PATHEXT/extension resolution inside the
218 // given directory, so a Windows `tsc.cmd`/`tsc.exe` resolves too.
219 which::which_in(tool, Some(dir.as_os_str()), dir).ok()
220 }
221
222 fn version(&self, path: &Path) -> Option<Version> {
223 let out = Command::new(path).arg("--version").output().ok()?;
224 let stdout = String::from_utf8_lossy(&out.stdout);
225 let parsed = Version::parse(&stdout);
226 if parsed.is_some() {
227 return parsed;
228 }
229 // Some tools print their version banner to stderr.
230 Version::parse(&String::from_utf8_lossy(&out.stderr))
231 }
232
233 fn npx_available(&self) -> bool {
234 which::which(OsStr::new("npx")).is_ok()
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 #[test]
243 fn parses_common_version_banners() {
244 assert_eq!(
245 Version::parse("v18.17.0"),
246 Some(Version {
247 major: 18,
248 minor: 17,
249 patch: 0
250 })
251 );
252 assert_eq!(
253 Version::parse("Version 5.4.2"),
254 Some(Version {
255 major: 5,
256 minor: 4,
257 patch: 2
258 })
259 );
260 assert_eq!(
261 Version::parse("⛅️ wrangler 3.90.0"),
262 Some(Version {
263 major: 3,
264 minor: 90,
265 patch: 0
266 })
267 );
268 assert_eq!(
269 Version::parse("v20"),
270 Some(Version {
271 major: 20,
272 minor: 0,
273 patch: 0
274 })
275 );
276 assert_eq!(Version::parse("no digits here"), None);
277 }
278}