use semver::Version;
pub trait VersionRequirementMatcher: Send + Sync {
fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool;
}
#[derive(Debug, Clone, Copy)]
pub struct SemverMatcher;
impl VersionRequirementMatcher for SemverMatcher {
fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool {
use semver::VersionReq;
let latest_ver = match latest.parse::<Version>() {
Ok(v) => v,
Err(_) => return requirement == latest,
};
if let Ok(req) = requirement.parse::<VersionReq>() {
return req.matches(&latest_ver);
}
if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
return req.matches(&latest_ver);
}
requirement == latest
}
}
#[derive(Debug, Clone, Copy)]
pub struct Pep440Matcher;
impl VersionRequirementMatcher for Pep440Matcher {
fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool {
let latest_ver = match normalize_and_parse_version(latest) {
Some(v) => v,
None => return requirement == latest,
};
let min_version = extract_pypi_min_version(requirement);
let min_ver = match min_version.and_then(|v| normalize_and_parse_version(&v)) {
Some(v) => v,
None => return requirement == latest,
};
if min_ver.major == 0 {
min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
} else {
min_ver.major == latest_ver.major
}
}
}
pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
if let Ok(v) = version.parse::<Version>() {
return Some(v);
}
let dot_count = version.chars().filter(|&c| c == '.').count();
let normalized = match dot_count {
0 => format!("{}.0.0", version), 1 => format!("{}.0", version), _ => version.to_string(),
};
normalized.parse::<Version>().ok()
}
pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
for part in version_req.split(',') {
let trimmed = part.trim();
if let Some(ver) = trimmed.strip_prefix(">=") {
return Some(ver.trim().to_string());
}
if let Some(ver) = trimmed.strip_prefix("~=") {
return Some(ver.trim().to_string());
}
if let Some(ver) = trimmed.strip_prefix("==") {
return Some(ver.trim().to_string());
}
if let Some(ver) = trimmed.strip_prefix('>') {
return Some(ver.trim().to_string());
}
}
let stripped = version_req.trim_start_matches('^').trim_start_matches('~');
if stripped.chars().next().is_some_and(|c| c.is_ascii_digit()) {
return Some(stripped.to_string());
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_semver_matcher_exact_match() {
let matcher = SemverMatcher;
assert!(matcher.is_latest_satisfying("1.0.0", "1.0.0"));
assert!(matcher.is_latest_satisfying("^1.0.0", "1.0.0"));
assert!(matcher.is_latest_satisfying("~1.0.0", "1.0.0"));
assert!(matcher.is_latest_satisfying("=1.0.0", "1.0.0"));
}
#[test]
fn test_semver_matcher_compatible_versions() {
let matcher = SemverMatcher;
assert!(matcher.is_latest_satisfying("1.0.0", "1.0.5")); assert!(matcher.is_latest_satisfying("^1.0.0", "1.5.0")); assert!(matcher.is_latest_satisfying("0.1", "0.1.83")); assert!(matcher.is_latest_satisfying("1", "1.5.0")); }
#[test]
fn test_semver_matcher_incompatible_versions() {
let matcher = SemverMatcher;
assert!(!matcher.is_latest_satisfying("1.0.0", "2.0.0")); assert!(!matcher.is_latest_satisfying("0.1", "0.2.0")); assert!(!matcher.is_latest_satisfying("~1.0.0", "1.1.0")); }
#[test]
fn test_pep440_matcher_same_major() {
let matcher = Pep440Matcher;
assert!(matcher.is_latest_satisfying(">=8.0", "8.3.5")); assert!(matcher.is_latest_satisfying(">=1.0", "1.5.0")); assert!(matcher.is_latest_satisfying(">=1.0,<2.0", "1.9.0")); }
#[test]
fn test_pep440_matcher_new_major() {
let matcher = Pep440Matcher;
assert!(!matcher.is_latest_satisfying(">=8.0", "9.0.2")); assert!(!matcher.is_latest_satisfying(">=1.0", "2.0.0")); assert!(!matcher.is_latest_satisfying(">=4.0,<8.0", "8.0.0")); }
#[test]
fn test_pep440_matcher_zero_version() {
let matcher = Pep440Matcher;
assert!(matcher.is_latest_satisfying(">=0.8", "0.8.5")); assert!(!matcher.is_latest_satisfying(">=0.8", "0.9.0")); }
#[test]
fn test_extract_pypi_min_version() {
assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
assert_eq!(
extract_pypi_min_version(">=1.0,<2.0"),
Some("1.0".to_string())
);
assert_eq!(
extract_pypi_min_version("~=1.4.2"),
Some("1.4.2".to_string())
);
assert_eq!(
extract_pypi_min_version("==2.0.0"),
Some("2.0.0".to_string())
);
assert_eq!(extract_pypi_min_version("^1.0"), Some("1.0".to_string())); assert_eq!(extract_pypi_min_version(">1.0"), Some("1.0".to_string()));
}
#[test]
fn test_normalize_and_parse_version() {
assert_eq!(
normalize_and_parse_version("1.0.0").unwrap().to_string(),
"1.0.0"
);
assert_eq!(
normalize_and_parse_version("1.0").unwrap().to_string(),
"1.0.0"
);
assert_eq!(
normalize_and_parse_version("8").unwrap().to_string(),
"8.0.0"
);
assert!(normalize_and_parse_version("invalid").is_none());
}
}