1use std::ffi::OsStr;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15
16#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
72pub enum Provenance {
73 Path(PathBuf),
75 ProjectLocal(PathBuf),
77 Npx,
80 Missing,
82}
83
84impl Provenance {
85 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 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#[derive(Debug, Clone)]
107pub struct Probe {
108 pub tool: String,
109 pub version: Option<Version>,
110 pub provenance: Provenance,
111}
112
113impl Probe {
114 pub fn is_present(&self) -> bool {
116 matches!(
117 self.provenance,
118 Provenance::Path(_) | Provenance::ProjectLocal(_)
119 )
120 }
121
122 pub fn is_provisionable(&self) -> bool {
124 matches!(self.provenance, Provenance::Npx)
125 }
126
127 pub fn is_missing(&self) -> bool {
129 matches!(self.provenance, Provenance::Missing)
130 }
131}
132
133#[derive(Debug, Clone, Copy, Default)]
137pub struct DetectOpts<'a> {
138 pub project_root: Option<&'a Path>,
139 pub allow_npx: bool,
140}
141
142pub trait Toolbox {
145 fn on_path(&self, tool: &str) -> Option<PathBuf>;
147 fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf>;
149 fn version(&self, path: &Path) -> Option<Version>;
151 fn npx_available(&self) -> bool;
153}
154
155pub 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#[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::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 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}