Skip to main content

deps_core/
version_matcher.rs

1//! Version requirement matching abstractions.
2//!
3//! Provides traits and implementations for version requirement matching
4//! across different package ecosystems (semver, PEP 440, etc.).
5
6use crate::ConcreteVersion;
7use semver::Version;
8use std::borrow::Cow;
9
10/// Generic version requirement matcher.
11///
12/// Each ecosystem implements this to provide version matching logic.
13/// Used by handlers to determine if a dependency is up-to-date.
14pub trait VersionRequirementMatcher: Send + Sync {
15    /// Check if the latest available version satisfies the requirement.
16    ///
17    /// Returns true if the dependency is "up to date" within its constraint.
18    ///
19    /// # Examples
20    ///
21    /// For Cargo/npm (semver):
22    /// - `"^1.0.0"` with latest `"1.5.0"` → true (satisfies ^1.0.0)
23    /// - `"^1.0.0"` with latest `"2.0.0"` → false (new major version)
24    ///
25    /// For PyPI (PEP 440):
26    /// - `">=8.0"` with latest `"8.3.5"` → true (same major version)
27    /// - `">=8.0"` with latest `"9.0.0"` → false (new major version)
28    fn is_latest_satisfying(&self, requirement: &ConcreteVersion, latest: &ConcreteVersion)
29    -> bool;
30}
31
32/// Semver-based version matcher for Cargo and npm.
33///
34/// Uses the semver crate to match version requirements.
35/// Handles caret (^) and tilde (~) requirements according to semver semantics.
36#[derive(Debug, Clone, Copy)]
37pub struct SemverMatcher;
38
39impl VersionRequirementMatcher for SemverMatcher {
40    fn is_latest_satisfying(
41        &self,
42        requirement: &ConcreteVersion,
43        latest: &ConcreteVersion,
44    ) -> bool {
45        use semver::VersionReq;
46
47        let requirement = requirement.as_str();
48        let latest = latest.as_str();
49
50        // Parse the latest version
51        let latest_ver = match latest.parse::<Version>() {
52            Ok(v) => v,
53            Err(_) => return requirement == latest,
54        };
55
56        // Try to parse as a semver requirement (handles ^, ~, =, etc.)
57        if let Ok(req) = requirement.parse::<VersionReq>() {
58            return req.matches(&latest_ver);
59        }
60
61        // If not a valid requirement, try treating it as a caret requirement
62        // (Cargo's default: "1.0" means "^1.0")
63        if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
64            return req.matches(&latest_ver);
65        }
66
67        // Fallback: string comparison
68        requirement == latest
69    }
70}
71
72/// PEP 440 version matcher for PyPI dependencies.
73///
74/// Implements major version comparison strategy:
75/// - For versions >= 1.0: compares major version only
76/// - For versions 0.x: compares major and minor version
77///
78/// This matches the typical Python ecosystem convention where breaking
79/// changes happen on major version bumps (or minor bumps for 0.x versions).
80#[derive(Debug, Clone, Copy)]
81pub struct Pep440Matcher;
82
83impl VersionRequirementMatcher for Pep440Matcher {
84    fn is_latest_satisfying(
85        &self,
86        requirement: &ConcreteVersion,
87        latest: &ConcreteVersion,
88    ) -> bool {
89        let requirement = requirement.as_str();
90        let latest = latest.as_str();
91
92        // Parse the latest version (normalize to three parts if needed)
93        let latest_ver = match normalize_and_parse_version(latest) {
94            Some(v) => v,
95            None => return requirement == latest,
96        };
97
98        // Extract the minimum version from the requirement
99        // Common patterns: ">=1.0", ">=1.0,<2.0", "~=1.0", "==1.0"
100        let min_version = extract_pypi_min_version(requirement);
101
102        let min_ver = match min_version.and_then(|v| normalize_and_parse_version(&v)) {
103            Some(v) => v,
104            None => return requirement == latest,
105        };
106
107        // Check if major versions match (for major version 0, also check minor)
108        if min_ver.major == 0 {
109            // For 0.x versions, both major and minor must match
110            min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
111        } else {
112            // For 1.x+, just major version must match
113            min_ver.major == latest_ver.major
114        }
115    }
116}
117
118/// Normalize a version string and parse it as semver.
119///
120/// Adds missing patch version if needed (e.g., "8.0" → "8.0.0").
121///
122/// # Examples
123///
124/// ```
125/// # use deps_core::version_matcher::normalize_and_parse_version;
126/// assert_eq!(normalize_and_parse_version("1.0.0").unwrap().to_string(), "1.0.0");
127/// assert_eq!(normalize_and_parse_version("1.0").unwrap().to_string(), "1.0.0");
128/// assert_eq!(normalize_and_parse_version("8").unwrap().to_string(), "8.0.0");
129/// ```
130pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
131    // Try parsing directly first
132    if let Ok(v) = version.parse::<Version>() {
133        return Some(v);
134    }
135
136    // Count dots to see if we need to add patch version
137    let dot_count = version.chars().filter(|&c| c == '.').count();
138
139    let normalized = match dot_count {
140        0 => format!("{}.0.0", version), // "8" → "8.0.0"
141        1 => format!("{}.0", version),   // "8.0" → "8.0.0"
142        _ => version.to_string(),
143    };
144
145    normalized.parse::<Version>().ok()
146}
147
148/// Extract the minimum version number from a PEP 440 version specifier.
149///
150/// # Examples
151///
152/// ```
153/// # use deps_core::version_matcher::extract_pypi_min_version;
154/// assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
155/// assert_eq!(extract_pypi_min_version(">=1.0,<2.0"), Some("1.0".to_string()));
156/// assert_eq!(extract_pypi_min_version("~=1.4.2"), Some("1.4.2".to_string()));
157/// assert_eq!(extract_pypi_min_version("==2.0.0"), Some("2.0.0".to_string()));
158/// ```
159pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
160    // Split by comma and look for >= or ~= or == specifiers
161    for part in version_req.split(',') {
162        let trimmed = part.trim();
163
164        // Handle different operators
165        if let Some(ver) = trimmed.strip_prefix(">=") {
166            return Some(ver.trim().to_string());
167        }
168        if let Some(ver) = trimmed.strip_prefix("~=") {
169            return Some(ver.trim().to_string());
170        }
171        if let Some(ver) = trimmed.strip_prefix("==") {
172            return Some(ver.trim().to_string());
173        }
174        if let Some(ver) = trimmed.strip_prefix('>') {
175            // > means strictly greater, but we use this as approximation
176            return Some(ver.trim().to_string());
177        }
178    }
179
180    // If no operator found, try parsing the whole string as a version
181    // (handles Poetry's "^1.0" style by stripping the ^)
182    let stripped = version_req.trim_start_matches('^').trim_start_matches('~');
183    if stripped.chars().next().is_some_and(|c| c.is_ascii_digit()) {
184        return Some(stripped.to_string());
185    }
186
187    None
188}
189
190/// Collapses whitespace between a range operator (`>=`, `<=`, `>`, `<`) and its version
191/// number, e.g. `">= 1.0 < 2.0"` becomes `">=1.0 <2.0"`.
192///
193/// Several ecosystem requirement grammars (Dart's pubspec constraints, Composer's version
194/// constraints) accept a space after a range operator, but the ecosystem's own AND-splitting
195/// logic (splitting a requirement on whitespace to get individual clauses) would otherwise
196/// treat the operator and its version as separate clauses.
197///
198/// Borrows `requirement` unchanged when there is no spaced operator to collapse (the common
199/// case) instead of always allocating — callers that check many candidate versions against
200/// the same requirement should normalize once and reuse the result rather than re-normalizing
201/// per candidate.
202///
203/// # Examples
204///
205/// ```
206/// # use deps_core::version_matcher::normalize_operator_spacing;
207/// assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
208/// assert_eq!(normalize_operator_spacing(">=1.0 <2.0"), ">=1.0 <2.0");
209/// ```
210pub fn normalize_operator_spacing(requirement: &str) -> Cow<'_, str> {
211    if !has_spaced_operator(requirement) {
212        return Cow::Borrowed(requirement);
213    }
214
215    let mut result = String::with_capacity(requirement.len());
216    let mut chars = requirement.chars().peekable();
217    while let Some(c) = chars.next() {
218        result.push(c);
219        if c == '>' || c == '<' {
220            if chars.peek() == Some(&'=') {
221                result.push('=');
222                chars.next();
223            }
224            while chars.peek().is_some_and(|ws| ws.is_whitespace()) {
225                chars.next();
226            }
227        }
228    }
229    Cow::Owned(result)
230}
231
232/// Reports whether `requirement` contains a `>`/`<`/`>=`/`<=` operator immediately followed
233/// by whitespace, i.e. whether [`normalize_operator_spacing`] would need to allocate. Pure
234/// scan, no allocation, so the common no-op case stays cheap.
235fn has_spaced_operator(requirement: &str) -> bool {
236    let mut chars = requirement.chars().peekable();
237    while let Some(c) = chars.next() {
238        if c == '>' || c == '<' {
239            if chars.peek() == Some(&'=') {
240                chars.next();
241            }
242            if chars.peek().is_some_and(|ws| ws.is_whitespace()) {
243                return true;
244            }
245        }
246    }
247    false
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    use std::assert_matches;
255
256    #[test]
257    fn test_semver_matcher_exact_match() {
258        let matcher = SemverMatcher;
259        assert!(matcher.is_latest_satisfying(
260            &ConcreteVersion::new("1.0.0"),
261            &ConcreteVersion::new("1.0.0")
262        ));
263        assert!(matcher.is_latest_satisfying(
264            &ConcreteVersion::new("^1.0.0"),
265            &ConcreteVersion::new("1.0.0")
266        ));
267        assert!(matcher.is_latest_satisfying(
268            &ConcreteVersion::new("~1.0.0"),
269            &ConcreteVersion::new("1.0.0")
270        ));
271        assert!(matcher.is_latest_satisfying(
272            &ConcreteVersion::new("=1.0.0"),
273            &ConcreteVersion::new("1.0.0")
274        ));
275    }
276
277    #[test]
278    fn test_semver_matcher_compatible_versions() {
279        let matcher = SemverMatcher;
280        // Latest version satisfies the requirement (up-to-date)
281        assert!(matcher.is_latest_satisfying(
282            &ConcreteVersion::new("1.0.0"),
283            &ConcreteVersion::new("1.0.5")
284        )); // ^1.0.0 allows 1.0.5
285        assert!(matcher.is_latest_satisfying(
286            &ConcreteVersion::new("^1.0.0"),
287            &ConcreteVersion::new("1.5.0")
288        )); // ^1.0.0 allows 1.5.0
289        assert!(matcher.is_latest_satisfying(
290            &ConcreteVersion::new("0.1"),
291            &ConcreteVersion::new("0.1.83")
292        )); // ^0.1 allows 0.1.83
293        assert!(
294            matcher
295                .is_latest_satisfying(&ConcreteVersion::new("1"), &ConcreteVersion::new("1.5.0"))
296        ); // ^1 allows 1.5.0
297    }
298
299    #[test]
300    fn test_semver_matcher_incompatible_versions() {
301        let matcher = SemverMatcher;
302        // Latest version doesn't satisfy requirement (new major available)
303        assert!(!matcher.is_latest_satisfying(
304            &ConcreteVersion::new("1.0.0"),
305            &ConcreteVersion::new("2.0.0")
306        )); // 2.0.0 breaks ^1.0.0
307        assert!(
308            !matcher
309                .is_latest_satisfying(&ConcreteVersion::new("0.1"), &ConcreteVersion::new("0.2.0"))
310        ); // 0.2.0 breaks ^0.1
311        assert!(!matcher.is_latest_satisfying(
312            &ConcreteVersion::new("~1.0.0"),
313            &ConcreteVersion::new("1.1.0")
314        )); // ~1.0.0 doesn't allow 1.1.0
315    }
316
317    #[test]
318    fn test_pep440_matcher_same_major() {
319        let matcher = Pep440Matcher;
320        // Same major version = up to date
321        assert!(matcher.is_latest_satisfying(
322            &ConcreteVersion::new(">=8.0"),
323            &ConcreteVersion::new("8.3.5")
324        )); // 8.x matches 8.x
325        assert!(matcher.is_latest_satisfying(
326            &ConcreteVersion::new(">=1.0"),
327            &ConcreteVersion::new("1.5.0")
328        )); // 1.x matches 1.x
329        assert!(matcher.is_latest_satisfying(
330            &ConcreteVersion::new(">=1.0,<2.0"),
331            &ConcreteVersion::new("1.9.0")
332        )); // constrained but same major
333    }
334
335    #[test]
336    fn test_pep440_matcher_new_major() {
337        let matcher = Pep440Matcher;
338        // New major version available = needs update
339        assert!(!matcher.is_latest_satisfying(
340            &ConcreteVersion::new(">=8.0"),
341            &ConcreteVersion::new("9.0.2")
342        )); // 8.x vs 9.x
343        assert!(!matcher.is_latest_satisfying(
344            &ConcreteVersion::new(">=1.0"),
345            &ConcreteVersion::new("2.0.0")
346        )); // 1.x vs 2.x
347        assert!(!matcher.is_latest_satisfying(
348            &ConcreteVersion::new(">=4.0,<8.0"),
349            &ConcreteVersion::new("8.0.0")
350        )); // 4.x vs 8.x
351    }
352
353    #[test]
354    fn test_pep440_matcher_zero_version() {
355        let matcher = Pep440Matcher;
356        // For 0.x versions, minor must also match
357        assert!(matcher.is_latest_satisfying(
358            &ConcreteVersion::new(">=0.8"),
359            &ConcreteVersion::new("0.8.5")
360        )); // 0.8.x matches 0.8.x
361        assert!(!matcher.is_latest_satisfying(
362            &ConcreteVersion::new(">=0.8"),
363            &ConcreteVersion::new("0.9.0")
364        )); // 0.8.x vs 0.9.x
365    }
366
367    #[test]
368    fn test_extract_pypi_min_version() {
369        assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
370        assert_eq!(
371            extract_pypi_min_version(">=1.0,<2.0"),
372            Some("1.0".to_string())
373        );
374        assert_eq!(
375            extract_pypi_min_version("~=1.4.2"),
376            Some("1.4.2".to_string())
377        );
378        assert_eq!(
379            extract_pypi_min_version("==2.0.0"),
380            Some("2.0.0".to_string())
381        );
382        assert_eq!(extract_pypi_min_version("^1.0"), Some("1.0".to_string())); // Poetry style
383        assert_eq!(extract_pypi_min_version(">1.0"), Some("1.0".to_string()));
384    }
385
386    #[test]
387    fn test_normalize_operator_spacing_collapses_spaced_operators() {
388        assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
389        assert_eq!(normalize_operator_spacing("> 1.0"), ">1.0");
390        assert_eq!(normalize_operator_spacing("<= 1.0"), "<=1.0");
391    }
392
393    #[test]
394    fn test_normalize_operator_spacing_borrows_when_no_spaced_operator() {
395        let input = ">=1.0 <2.0";
396        assert_matches!(
397            normalize_operator_spacing(input),
398            Cow::Borrowed(s) if s == input
399        );
400    }
401
402    #[test]
403    fn test_normalize_and_parse_version() {
404        assert_eq!(
405            normalize_and_parse_version("1.0.0").unwrap().to_string(),
406            "1.0.0"
407        );
408        assert_eq!(
409            normalize_and_parse_version("1.0").unwrap().to_string(),
410            "1.0.0"
411        );
412        assert_eq!(
413            normalize_and_parse_version("8").unwrap().to_string(),
414            "8.0.0"
415        );
416        assert!(normalize_and_parse_version("invalid").is_none());
417    }
418}