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 semver::Version;
7
8/// Generic version requirement matcher.
9///
10/// Each ecosystem implements this to provide version matching logic.
11/// Used by handlers to determine if a dependency is up-to-date.
12pub trait VersionRequirementMatcher: Send + Sync {
13    /// Check if the latest available version satisfies the requirement.
14    ///
15    /// Returns true if the dependency is "up to date" within its constraint.
16    ///
17    /// # Examples
18    ///
19    /// For Cargo/npm (semver):
20    /// - `"^1.0.0"` with latest `"1.5.0"` → true (satisfies ^1.0.0)
21    /// - `"^1.0.0"` with latest `"2.0.0"` → false (new major version)
22    ///
23    /// For PyPI (PEP 440):
24    /// - `">=8.0"` with latest `"8.3.5"` → true (same major version)
25    /// - `">=8.0"` with latest `"9.0.0"` → false (new major version)
26    fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool;
27}
28
29/// Semver-based version matcher for Cargo and npm.
30///
31/// Uses the semver crate to match version requirements.
32/// Handles caret (^) and tilde (~) requirements according to semver semantics.
33#[derive(Debug, Clone, Copy)]
34pub struct SemverMatcher;
35
36impl VersionRequirementMatcher for SemverMatcher {
37    fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool {
38        use semver::VersionReq;
39
40        // Parse the latest version
41        let latest_ver = match latest.parse::<Version>() {
42            Ok(v) => v,
43            Err(_) => return requirement == latest,
44        };
45
46        // Try to parse as a semver requirement (handles ^, ~, =, etc.)
47        if let Ok(req) = requirement.parse::<VersionReq>() {
48            return req.matches(&latest_ver);
49        }
50
51        // If not a valid requirement, try treating it as a caret requirement
52        // (Cargo's default: "1.0" means "^1.0")
53        if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
54            return req.matches(&latest_ver);
55        }
56
57        // Fallback: string comparison
58        requirement == latest
59    }
60}
61
62/// PEP 440 version matcher for PyPI dependencies.
63///
64/// Implements major version comparison strategy:
65/// - For versions >= 1.0: compares major version only
66/// - For versions 0.x: compares major and minor version
67///
68/// This matches the typical Python ecosystem convention where breaking
69/// changes happen on major version bumps (or minor bumps for 0.x versions).
70#[derive(Debug, Clone, Copy)]
71pub struct Pep440Matcher;
72
73impl VersionRequirementMatcher for Pep440Matcher {
74    fn is_latest_satisfying(&self, requirement: &str, latest: &str) -> bool {
75        // Parse the latest version (normalize to three parts if needed)
76        let latest_ver = match normalize_and_parse_version(latest) {
77            Some(v) => v,
78            None => return requirement == latest,
79        };
80
81        // Extract the minimum version from the requirement
82        // Common patterns: ">=1.0", ">=1.0,<2.0", "~=1.0", "==1.0"
83        let min_version = extract_pypi_min_version(requirement);
84
85        let min_ver = match min_version.and_then(|v| normalize_and_parse_version(&v)) {
86            Some(v) => v,
87            None => return requirement == latest,
88        };
89
90        // Check if major versions match (for major version 0, also check minor)
91        if min_ver.major == 0 {
92            // For 0.x versions, both major and minor must match
93            min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
94        } else {
95            // For 1.x+, just major version must match
96            min_ver.major == latest_ver.major
97        }
98    }
99}
100
101/// Normalize a version string and parse it as semver.
102///
103/// Adds missing patch version if needed (e.g., "8.0" → "8.0.0").
104///
105/// # Examples
106///
107/// ```
108/// # use deps_core::version_matcher::normalize_and_parse_version;
109/// assert_eq!(normalize_and_parse_version("1.0.0").unwrap().to_string(), "1.0.0");
110/// assert_eq!(normalize_and_parse_version("1.0").unwrap().to_string(), "1.0.0");
111/// assert_eq!(normalize_and_parse_version("8").unwrap().to_string(), "8.0.0");
112/// ```
113pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
114    // Try parsing directly first
115    if let Ok(v) = version.parse::<Version>() {
116        return Some(v);
117    }
118
119    // Count dots to see if we need to add patch version
120    let dot_count = version.chars().filter(|&c| c == '.').count();
121
122    let normalized = match dot_count {
123        0 => format!("{}.0.0", version), // "8" → "8.0.0"
124        1 => format!("{}.0", version),   // "8.0" → "8.0.0"
125        _ => version.to_string(),
126    };
127
128    normalized.parse::<Version>().ok()
129}
130
131/// Extract the minimum version number from a PEP 440 version specifier.
132///
133/// # Examples
134///
135/// ```
136/// # use deps_core::version_matcher::extract_pypi_min_version;
137/// assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
138/// assert_eq!(extract_pypi_min_version(">=1.0,<2.0"), Some("1.0".to_string()));
139/// assert_eq!(extract_pypi_min_version("~=1.4.2"), Some("1.4.2".to_string()));
140/// assert_eq!(extract_pypi_min_version("==2.0.0"), Some("2.0.0".to_string()));
141/// ```
142pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
143    // Split by comma and look for >= or ~= or == specifiers
144    for part in version_req.split(',') {
145        let trimmed = part.trim();
146
147        // Handle different operators
148        if let Some(ver) = trimmed.strip_prefix(">=") {
149            return Some(ver.trim().to_string());
150        }
151        if let Some(ver) = trimmed.strip_prefix("~=") {
152            return Some(ver.trim().to_string());
153        }
154        if let Some(ver) = trimmed.strip_prefix("==") {
155            return Some(ver.trim().to_string());
156        }
157        if let Some(ver) = trimmed.strip_prefix('>') {
158            // > means strictly greater, but we use this as approximation
159            return Some(ver.trim().to_string());
160        }
161    }
162
163    // If no operator found, try parsing the whole string as a version
164    // (handles Poetry's "^1.0" style by stripping the ^)
165    let stripped = version_req.trim_start_matches('^').trim_start_matches('~');
166    if stripped.chars().next().is_some_and(|c| c.is_ascii_digit()) {
167        return Some(stripped.to_string());
168    }
169
170    None
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_semver_matcher_exact_match() {
179        let matcher = SemverMatcher;
180        assert!(matcher.is_latest_satisfying("1.0.0", "1.0.0"));
181        assert!(matcher.is_latest_satisfying("^1.0.0", "1.0.0"));
182        assert!(matcher.is_latest_satisfying("~1.0.0", "1.0.0"));
183        assert!(matcher.is_latest_satisfying("=1.0.0", "1.0.0"));
184    }
185
186    #[test]
187    fn test_semver_matcher_compatible_versions() {
188        let matcher = SemverMatcher;
189        // Latest version satisfies the requirement (up-to-date)
190        assert!(matcher.is_latest_satisfying("1.0.0", "1.0.5")); // ^1.0.0 allows 1.0.5
191        assert!(matcher.is_latest_satisfying("^1.0.0", "1.5.0")); // ^1.0.0 allows 1.5.0
192        assert!(matcher.is_latest_satisfying("0.1", "0.1.83")); // ^0.1 allows 0.1.83
193        assert!(matcher.is_latest_satisfying("1", "1.5.0")); // ^1 allows 1.5.0
194    }
195
196    #[test]
197    fn test_semver_matcher_incompatible_versions() {
198        let matcher = SemverMatcher;
199        // Latest version doesn't satisfy requirement (new major available)
200        assert!(!matcher.is_latest_satisfying("1.0.0", "2.0.0")); // 2.0.0 breaks ^1.0.0
201        assert!(!matcher.is_latest_satisfying("0.1", "0.2.0")); // 0.2.0 breaks ^0.1
202        assert!(!matcher.is_latest_satisfying("~1.0.0", "1.1.0")); // ~1.0.0 doesn't allow 1.1.0
203    }
204
205    #[test]
206    fn test_pep440_matcher_same_major() {
207        let matcher = Pep440Matcher;
208        // Same major version = up to date
209        assert!(matcher.is_latest_satisfying(">=8.0", "8.3.5")); // 8.x matches 8.x
210        assert!(matcher.is_latest_satisfying(">=1.0", "1.5.0")); // 1.x matches 1.x
211        assert!(matcher.is_latest_satisfying(">=1.0,<2.0", "1.9.0")); // constrained but same major
212    }
213
214    #[test]
215    fn test_pep440_matcher_new_major() {
216        let matcher = Pep440Matcher;
217        // New major version available = needs update
218        assert!(!matcher.is_latest_satisfying(">=8.0", "9.0.2")); // 8.x vs 9.x
219        assert!(!matcher.is_latest_satisfying(">=1.0", "2.0.0")); // 1.x vs 2.x
220        assert!(!matcher.is_latest_satisfying(">=4.0,<8.0", "8.0.0")); // 4.x vs 8.x
221    }
222
223    #[test]
224    fn test_pep440_matcher_zero_version() {
225        let matcher = Pep440Matcher;
226        // For 0.x versions, minor must also match
227        assert!(matcher.is_latest_satisfying(">=0.8", "0.8.5")); // 0.8.x matches 0.8.x
228        assert!(!matcher.is_latest_satisfying(">=0.8", "0.9.0")); // 0.8.x vs 0.9.x
229    }
230
231    #[test]
232    fn test_extract_pypi_min_version() {
233        assert_eq!(extract_pypi_min_version(">=8.0"), Some("8.0".to_string()));
234        assert_eq!(
235            extract_pypi_min_version(">=1.0,<2.0"),
236            Some("1.0".to_string())
237        );
238        assert_eq!(
239            extract_pypi_min_version("~=1.4.2"),
240            Some("1.4.2".to_string())
241        );
242        assert_eq!(
243            extract_pypi_min_version("==2.0.0"),
244            Some("2.0.0".to_string())
245        );
246        assert_eq!(extract_pypi_min_version("^1.0"), Some("1.0".to_string())); // Poetry style
247        assert_eq!(extract_pypi_min_version(">1.0"), Some("1.0".to_string()));
248    }
249
250    #[test]
251    fn test_normalize_and_parse_version() {
252        assert_eq!(
253            normalize_and_parse_version("1.0.0").unwrap().to_string(),
254            "1.0.0"
255        );
256        assert_eq!(
257            normalize_and_parse_version("1.0").unwrap().to_string(),
258            "1.0.0"
259        );
260        assert_eq!(
261            normalize_and_parse_version("8").unwrap().to_string(),
262            "8.0.0"
263        );
264        assert!(normalize_and_parse_version("invalid").is_none());
265    }
266}