diff --git a/crates/uv-pep440/src/version_specifier.rs b/crates/uv-pep440/src/version_specifier.rs index 17928b881218c..7a6d1d7048f2d 100644 --- a/crates/uv-pep440/src/version_specifier.rs +++ b/crates/uv-pep440/src/version_specifier.rs @@ -1,6 +1,7 @@ use std::borrow::Cow; use std::cmp::Ordering; use std::fmt::Formatter; +use std::hash::{Hash, Hasher}; use std::ops::Bound; use std::str::FromStr; @@ -269,7 +270,7 @@ impl VersionSpecifiersParseError { impl std::error::Error for VersionSpecifiersParseError {} /// A version range such as `>1.2.3`, `<=4!5.6.7-a8.post9.dev0` or `== 4.1.*`. Parse with -/// `VersionSpecifier::from_str` +/// [`VersionSpecifier::from_str`]. /// /// ```rust /// use std::str::FromStr; @@ -279,7 +280,12 @@ impl std::error::Error for VersionSpecifiersParseError {} /// let version_specifier = VersionSpecifier::from_str("== 1.*").unwrap(); /// assert!(version_specifier.contains(&version)); /// ``` -#[derive(Eq, Ord, PartialEq, PartialOrd, Debug, Clone, Hash)] +/// +/// [`PartialEq`], [`Hash`] and [`Ord`] distinguish `~=` specifiers by their +/// release segment count, since `~=10.1.0` (`>=10.1.0, <10.2`) and `~=10.1` +/// (`>=10.1, <11`) match different version sets per PEP 440. For other +/// operators, trailing zeros are insignificant. +#[derive(Debug, Clone)] #[cfg_attr( feature = "rkyv", derive(rkyv::Archive, rkyv::Deserialize, rkyv::Serialize) @@ -292,6 +298,60 @@ pub struct VersionSpecifier { pub(crate) version: Version, } +impl PartialEq for VersionSpecifier { + fn eq(&self, other: &Self) -> bool { + if self.operator != other.operator { + return false; + } + // `~=` semantics depend on the exact release segment count. + if self.operator == Operator::TildeEqual + && self.version.release().len() != other.version.release().len() + { + return false; + } + self.version == other.version + } +} + +impl Eq for VersionSpecifier {} + +impl Hash for VersionSpecifier { + fn hash(&self, state: &mut H) { + self.operator.hash(state); + // Include the release length for `~=` so that `~=10.1` and `~=10.1.0` + // hash differently, matching our `PartialEq`. + if self.operator == Operator::TildeEqual { + self.version.release().len().hash(state); + } + self.version.hash(state); + } +} + +impl PartialOrd for VersionSpecifier { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for VersionSpecifier { + fn cmp(&self, other: &Self) -> Ordering { + self.operator + .cmp(&other.operator) + .then_with(|| self.version.cmp(&other.version)) + .then_with(|| { + // Break `~=` ties on release length to stay consistent with `PartialEq`. + if self.operator == Operator::TildeEqual { + self.version + .release() + .len() + .cmp(&other.version.release().len()) + } else { + Ordering::Equal + } + }) + } +} + impl<'de> Deserialize<'de> for VersionSpecifier { fn deserialize(deserializer: D) -> Result where @@ -2052,6 +2112,40 @@ Failed to parse version: Unexpected end of version specifier, expected operator. ); } + /// PEP 440 states that trailing zeros in `~=` specifiers control forward + /// compatibility, so `~=2.2` ≠ `~=2.2.0`. Non-`~=` specifiers are unaffected. + #[test] + fn trailing_zero_equality() { + let equal = [ + // Non-`~=` operators: trailing zeros are insignificant. + (">=3.3", ">=3.3.0"), + ("<2", "<2.0.0"), + ("==1.2", "==1.2.0"), + // Identical `~=` specifiers. + ("~=2.2.0", "~=2.2.0"), + ]; + for (a, b) in equal { + let a = VersionSpecifier::from_str(a).unwrap(); + let b = VersionSpecifier::from_str(b).unwrap(); + assert_eq!(a, b); + } + + let not_equal = [ + // PEP 440 forward-compat examples. + ("~=2.2", "~=2.2.0"), + ("~=1.4.5", "~=1.4.5.0"), + // Same release, different suffix. + ("~=2.2.post3", "~=2.2.post5"), + // Different release length with matching suffix. + ("~=2.2.post3", "~=2.2.0.post3"), + ]; + for (a, b) in not_equal { + let a = VersionSpecifier::from_str(a).unwrap(); + let b = VersionSpecifier::from_str(b).unwrap(); + assert_ne!(a, b); + } + } + /// Do not panic with `u64::MAX` causing an `u64::MAX + 1` overflow. #[test] fn bounding_specifiers_u64_max_rejected_at_parse_time() {