use crate::ConcreteVersion;
use semver::Version;
use std::borrow::Cow;
pub trait VersionRequirementMatcher: Send + Sync {
fn is_latest_satisfying(&self, requirement: &ConcreteVersion, latest: &ConcreteVersion)
-> bool;
}
#[derive(Debug, Clone, Copy)]
pub struct SemverMatcher;
impl VersionRequirementMatcher for SemverMatcher {
fn is_latest_satisfying(
&self,
requirement: &ConcreteVersion,
latest: &ConcreteVersion,
) -> bool {
use semver::VersionReq;
let requirement = requirement.as_str();
let latest = latest.as_str();
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: &ConcreteVersion,
latest: &ConcreteVersion,
) -> bool {
let requirement = requirement.as_str();
let latest = latest.as_str();
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
}
pub fn normalize_operator_spacing(requirement: &str) -> Cow<'_, str> {
if !has_spaced_operator(requirement) {
return Cow::Borrowed(requirement);
}
let mut result = String::with_capacity(requirement.len());
let mut chars = requirement.chars().peekable();
while let Some(c) = chars.next() {
result.push(c);
if c == '>' || c == '<' {
if chars.peek() == Some(&'=') {
result.push('=');
chars.next();
}
while chars.peek().is_some_and(|ws| ws.is_whitespace()) {
chars.next();
}
}
}
Cow::Owned(result)
}
fn has_spaced_operator(requirement: &str) -> bool {
let mut chars = requirement.chars().peekable();
while let Some(c) = chars.next() {
if c == '>' || c == '<' {
if chars.peek() == Some(&'=') {
chars.next();
}
if chars.peek().is_some_and(|ws| ws.is_whitespace()) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::assert_matches;
#[test]
fn test_semver_matcher_exact_match() {
let matcher = SemverMatcher;
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("1.0.0"),
&ConcreteVersion::new("1.0.0")
));
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("^1.0.0"),
&ConcreteVersion::new("1.0.0")
));
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("~1.0.0"),
&ConcreteVersion::new("1.0.0")
));
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("=1.0.0"),
&ConcreteVersion::new("1.0.0")
));
}
#[test]
fn test_semver_matcher_compatible_versions() {
let matcher = SemverMatcher;
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("1.0.0"),
&ConcreteVersion::new("1.0.5")
)); assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("^1.0.0"),
&ConcreteVersion::new("1.5.0")
)); assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new("0.1"),
&ConcreteVersion::new("0.1.83")
)); assert!(
matcher
.is_latest_satisfying(&ConcreteVersion::new("1"), &ConcreteVersion::new("1.5.0"))
); }
#[test]
fn test_semver_matcher_incompatible_versions() {
let matcher = SemverMatcher;
assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new("1.0.0"),
&ConcreteVersion::new("2.0.0")
)); assert!(
!matcher
.is_latest_satisfying(&ConcreteVersion::new("0.1"), &ConcreteVersion::new("0.2.0"))
); assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new("~1.0.0"),
&ConcreteVersion::new("1.1.0")
)); }
#[test]
fn test_pep440_matcher_same_major() {
let matcher = Pep440Matcher;
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new(">=8.0"),
&ConcreteVersion::new("8.3.5")
)); assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new(">=1.0"),
&ConcreteVersion::new("1.5.0")
)); assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new(">=1.0,<2.0"),
&ConcreteVersion::new("1.9.0")
)); }
#[test]
fn test_pep440_matcher_new_major() {
let matcher = Pep440Matcher;
assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new(">=8.0"),
&ConcreteVersion::new("9.0.2")
)); assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new(">=1.0"),
&ConcreteVersion::new("2.0.0")
)); assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new(">=4.0,<8.0"),
&ConcreteVersion::new("8.0.0")
)); }
#[test]
fn test_pep440_matcher_zero_version() {
let matcher = Pep440Matcher;
assert!(matcher.is_latest_satisfying(
&ConcreteVersion::new(">=0.8"),
&ConcreteVersion::new("0.8.5")
)); assert!(!matcher.is_latest_satisfying(
&ConcreteVersion::new(">=0.8"),
&ConcreteVersion::new("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_operator_spacing_collapses_spaced_operators() {
assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
assert_eq!(normalize_operator_spacing("> 1.0"), ">1.0");
assert_eq!(normalize_operator_spacing("<= 1.0"), "<=1.0");
}
#[test]
fn test_normalize_operator_spacing_borrows_when_no_spaced_operator() {
let input = ">=1.0 <2.0";
assert_matches!(
normalize_operator_spacing(input),
Cow::Borrowed(s) if s == input
);
}
#[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());
}
}