use once_cell::sync::Lazy;
use regex::Regex;
use semver::{Version, VersionReq};
static VERSION_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r"v?(\d+)\.(\d+)(?:\.(\d+))?(?:-([a-zA-Z0-9.-]+))?").unwrap());
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractedVersion {
pub major: u64,
pub minor: u64,
pub patch: u64,
pub prerelease: Option<String>,
}
impl ExtractedVersion {
#[inline]
pub fn to_semver(&self) -> Option<Version> {
let version_str = if let Some(ref pre) = self.prerelease {
format!("{}.{}.{}-{}", self.major, self.minor, self.patch, pre)
} else {
format!("{}.{}.{}", self.major, self.minor, self.patch)
};
Version::parse(&version_str).ok()
}
}
pub fn extract_version(output: &str) -> Option<ExtractedVersion> {
VERSION_REGEX.captures(output).map(|caps| ExtractedVersion {
major: caps.get(1).unwrap().as_str().parse().unwrap_or(0),
minor: caps.get(2).unwrap().as_str().parse().unwrap_or(0),
patch: caps
.get(3)
.map(|m| m.as_str().parse().unwrap_or(0))
.unwrap_or(0),
prerelease: caps.get(4).map(|m| m.as_str().to_string()),
})
}
#[inline]
pub fn version_satisfies(installed_output: &str, requirement: &str) -> bool {
let requirement = requirement.trim();
if requirement.is_empty() || requirement == "latest" || requirement == "*" {
return true;
}
let installed = match extract_version(installed_output) {
Some(v) => v,
None => {
return contains_version_prefix(installed_output, requirement);
}
};
let installed_semver = match installed.to_semver() {
Some(v) => v,
None => {
return contains_version_prefix(installed_output, requirement);
}
};
if let Some(req) = parse_requirement(requirement) {
return req.matches(&installed_semver);
}
contains_version_prefix(installed_output, requirement)
}
fn parse_requirement(requirement: &str) -> Option<VersionReq> {
let has_operator = requirement.starts_with('>')
|| requirement.starts_with('<')
|| requirement.starts_with('=')
|| requirement.starts_with('^')
|| requirement.starts_with('~');
if has_operator {
return VersionReq::parse(requirement).ok();
}
let parts: Vec<&str> = requirement.split('.').collect();
let prefix_req = match parts.len() {
1 => {
if let Ok(major) = parts[0].parse::<u64>() {
format!(">={}.0.0, <{}.0.0", major, major + 1)
} else {
return None;
}
}
2 => {
if let (Ok(major), Ok(minor)) = (parts[0].parse::<u64>(), parts[1].parse::<u64>()) {
format!(">={}.{}.0, <{}.{}.0", major, minor, major, minor + 1)
} else {
return None;
}
}
3 => {
format!("={}", requirement)
}
_ => return None,
};
VersionReq::parse(&prefix_req).ok()
}
fn contains_version_prefix(output: &str, requirement: &str) -> bool {
for caps in VERSION_REGEX.captures_iter(output) {
let full_match = caps.get(0).unwrap().as_str();
if full_match.starts_with(requirement)
|| full_match.trim_start_matches('v').starts_with(requirement)
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_git_version() {
let v = extract_version("git version 2.44.0").unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 44);
assert_eq!(v.patch, 0);
assert_eq!(v.prerelease, None);
}
#[test]
fn test_extract_node_version() {
let v = extract_version("v20.10.0").unwrap();
assert_eq!(v.major, 20);
assert_eq!(v.minor, 10);
assert_eq!(v.patch, 0);
}
#[test]
fn test_extract_python_version() {
let v = extract_version("Python 3.12.1").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, 12);
assert_eq!(v.patch, 1);
}
#[test]
fn test_extract_docker_version() {
let v = extract_version("Docker version 24.0.7, build afdd53b").unwrap();
assert_eq!(v.major, 24);
assert_eq!(v.minor, 0);
assert_eq!(v.patch, 7);
}
#[test]
fn test_extract_rustc_version() {
let v = extract_version("rustc 1.75.0 (82e1608df 2023-12-21)").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 75);
assert_eq!(v.patch, 0);
}
#[test]
fn test_extract_go_version() {
let v = extract_version("go version go1.21.5 darwin/arm64").unwrap();
assert_eq!(v.major, 1);
assert_eq!(v.minor, 21);
assert_eq!(v.patch, 5);
}
#[test]
fn test_extract_version_with_prerelease() {
let v = extract_version("v3.10.0-beta.1").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, 10);
assert_eq!(v.patch, 0);
assert_eq!(v.prerelease, Some("beta.1".to_string()));
}
#[test]
fn test_extract_version_missing_patch() {
let v = extract_version("v3.10").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, 10);
assert_eq!(v.patch, 0);
}
#[test]
fn test_satisfies_latest() {
assert!(version_satisfies("anything 1.0.0", "latest"));
assert!(version_satisfies("anything 99.99.99", "latest"));
}
#[test]
fn test_satisfies_wildcard() {
assert!(version_satisfies("version 1.0.0", "*"));
assert!(version_satisfies("version 99.0.0", "*"));
}
#[test]
fn test_satisfies_empty_requirement() {
assert!(version_satisfies("version 1.0.0", ""));
}
#[test]
fn test_satisfies_prefix_major_minor() {
assert!(version_satisfies("git version 2.44.0", "2.44"));
assert!(version_satisfies("git version 2.44.5", "2.44"));
assert!(!version_satisfies("git version 2.43.0", "2.44"));
assert!(!version_satisfies("git version 2.45.0", "2.44"));
}
#[test]
fn test_satisfies_prefix_major_only() {
assert!(version_satisfies("v20.0.0", "20"));
assert!(version_satisfies("v20.10.5", "20"));
assert!(!version_satisfies("v19.0.0", "20"));
assert!(!version_satisfies("v21.0.0", "20"));
}
#[test]
fn test_satisfies_exact_version() {
assert!(version_satisfies("v3.10.5", "3.10.5"));
assert!(!version_satisfies("v3.10.4", "3.10.5"));
assert!(!version_satisfies("v3.10.6", "3.10.5"));
}
#[test]
fn test_satisfies_minimum_version() {
assert!(version_satisfies("Python 3.10.0", ">= 3.10"));
assert!(version_satisfies("Python 3.11.0", ">= 3.10"));
assert!(version_satisfies("Python 4.0.0", ">= 3.10"));
assert!(!version_satisfies("Python 3.9.0", ">= 3.10"));
}
#[test]
fn test_satisfies_maximum_version() {
assert!(version_satisfies("Python 3.9.0", "< 4.0"));
assert!(version_satisfies("Python 3.99.99", "< 4.0"));
assert!(!version_satisfies("Python 4.0.0", "< 4.0"));
assert!(!version_satisfies("Python 4.1.0", "< 4.0"));
}
#[test]
fn test_satisfies_range() {
assert!(version_satisfies("Python 3.10.0", ">= 3.10, < 4.0"));
assert!(version_satisfies("Python 3.12.5", ">= 3.10, < 4.0"));
assert!(!version_satisfies("Python 3.9.0", ">= 3.10, < 4.0"));
assert!(!version_satisfies("Python 4.0.0", ">= 3.10, < 4.0"));
}
#[test]
fn test_satisfies_caret() {
assert!(version_satisfies("rustc 1.70.0", "^1.70"));
assert!(version_satisfies("rustc 1.75.0", "^1.70"));
assert!(!version_satisfies("rustc 1.69.0", "^1.70"));
assert!(!version_satisfies("rustc 2.0.0", "^1.70"));
}
#[test]
fn test_satisfies_tilde() {
assert!(version_satisfies("Python 3.10.0", "~3.10"));
assert!(version_satisfies("Python 3.10.5", "~3.10"));
assert!(!version_satisfies("Python 3.11.0", "~3.10"));
assert!(!version_satisfies("Python 3.9.0", "~3.10"));
}
#[test]
fn test_no_false_positive_substring() {
assert!(!version_satisfies("version 12.40.0", "2.4"));
assert!(!version_satisfies("version 1.24.0", "2.4"));
}
#[test]
fn test_no_false_positive_embedded_number() {
assert!(!version_satisfies("Python 3.10.0", "3.1"));
assert!(!version_satisfies("Python 3.11.0", "3.1"));
assert!(version_satisfies("Python 3.1.0", "3.1"));
assert!(version_satisfies("Python 3.1.5", "3.1"));
}
#[test]
fn test_handles_unparseable_output() {
assert!(version_satisfies("some weird output 2.0", "2.0"));
}
#[test]
fn test_handles_multiple_versions_in_output() {
let output = "tool 1.0.0 (built with lib 2.5.0)";
assert!(version_satisfies(output, "1.0"));
}
#[test]
fn test_prerelease_not_matched_by_release() {
let pre = extract_version("v3.10.0-beta.1").unwrap();
let release = extract_version("v3.10.0").unwrap();
assert_ne!(pre, release);
}
}