use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
pub fn parse(s: &str) -> Option<Version> {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() && !b[i].is_ascii_digit() {
i += 1;
}
if i >= b.len() {
return None;
}
let mut nums = [0u32; 3];
let mut slot = 0;
while i < b.len() && slot < 3 {
let start = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
nums[slot] = s[start..i].parse().ok()?;
slot += 1;
if i < b.len() && b[i] == b'.' {
i += 1;
} else {
break;
}
}
Some(Version {
major: nums[0],
minor: nums[1],
patch: nums[2],
})
}
}
impl std::fmt::Display for Version {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Provenance {
Path(PathBuf),
ProjectLocal(PathBuf),
Npx,
Missing,
}
impl Provenance {
pub fn token(&self) -> &'static str {
match self {
Provenance::Path(_) => "path",
Provenance::ProjectLocal(_) => "project-local",
Provenance::Npx => "npx",
Provenance::Missing => "missing",
}
}
pub fn path(&self) -> Option<&Path> {
match self {
Provenance::Path(p) | Provenance::ProjectLocal(p) => Some(p),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct Probe {
pub tool: String,
pub version: Option<Version>,
pub provenance: Provenance,
}
impl Probe {
pub fn is_present(&self) -> bool {
matches!(
self.provenance,
Provenance::Path(_) | Provenance::ProjectLocal(_)
)
}
pub fn is_provisionable(&self) -> bool {
matches!(self.provenance, Provenance::Npx)
}
pub fn is_missing(&self) -> bool {
matches!(self.provenance, Provenance::Missing)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DetectOpts<'a> {
pub project_root: Option<&'a Path>,
pub allow_npx: bool,
}
pub trait Toolbox {
fn on_path(&self, tool: &str) -> Option<PathBuf>;
fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf>;
fn version(&self, path: &Path) -> Option<Version>;
fn npx_available(&self) -> bool;
}
pub fn detect(tb: &dyn Toolbox, tool: &str, opts: DetectOpts<'_>) -> Probe {
if let Some(root) = opts.project_root {
let bin = root.join("node_modules").join(".bin");
if let Some(p) = tb.in_dir(&bin, tool) {
let version = tb.version(&p);
return Probe {
tool: tool.to_string(),
version,
provenance: Provenance::ProjectLocal(p),
};
}
}
if let Some(p) = tb.on_path(tool) {
let version = tb.version(&p);
return Probe {
tool: tool.to_string(),
version,
provenance: Provenance::Path(p),
};
}
if opts.allow_npx && tb.npx_available() {
return Probe {
tool: tool.to_string(),
version: None,
provenance: Provenance::Npx,
};
}
Probe {
tool: tool.to_string(),
version: None,
provenance: Provenance::Missing,
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemToolbox;
impl Toolbox for SystemToolbox {
fn on_path(&self, tool: &str) -> Option<PathBuf> {
which::which(tool).ok()
}
fn in_dir(&self, dir: &Path, tool: &str) -> Option<PathBuf> {
which::which_in(tool, Some(dir.as_os_str()), dir).ok()
}
fn version(&self, path: &Path) -> Option<Version> {
let out = Command::new(path).arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&out.stdout);
let parsed = Version::parse(&stdout);
if parsed.is_some() {
return parsed;
}
Version::parse(&String::from_utf8_lossy(&out.stderr))
}
fn npx_available(&self) -> bool {
which::which(OsStr::new("npx")).is_ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_common_version_banners() {
assert_eq!(
Version::parse("v18.17.0"),
Some(Version {
major: 18,
minor: 17,
patch: 0
})
);
assert_eq!(
Version::parse("Version 5.4.2"),
Some(Version {
major: 5,
minor: 4,
patch: 2
})
);
assert_eq!(
Version::parse("⛅️ wrangler 3.90.0"),
Some(Version {
major: 3,
minor: 90,
patch: 0
})
);
assert_eq!(
Version::parse("v20"),
Some(Version {
major: 20,
minor: 0,
patch: 0
})
);
assert_eq!(Version::parse("no digits here"), None);
}
}