deps-core 1.0.0

Core abstractions for deps-lsp: caching, errors, and traits
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Version requirement matching abstractions.
//!
//! Provides traits and implementations for version requirement matching
//! across different package ecosystems (semver, PEP 440, etc.).

use crate::ConcreteVersion;
use semver::Version;
use std::borrow::Cow;

/// Generic version requirement matcher.
///
/// Each ecosystem implements this to provide version matching logic.
/// Used by handlers to determine if a dependency is up-to-date.
pub trait VersionRequirementMatcher: Send + Sync {
    /// Check if the latest available version satisfies the requirement.
    ///
    /// Returns true if the dependency is "up to date" within its constraint.
    ///
    /// # Examples
    ///
    /// For Cargo/npm (semver):
    /// - `"^1.0.0"` with latest `"1.5.0"` → true (satisfies ^1.0.0)
    /// - `"^1.0.0"` with latest `"2.0.0"` → false (new major version)
    ///
    /// For PyPI (PEP 440):
    /// - `">=8.0"` with latest `"8.3.5"` → true (same major version)
    /// - `">=8.0"` with latest `"9.0.0"` → false (new major version)
    fn is_latest_satisfying(&self, requirement: &ConcreteVersion, latest: &ConcreteVersion)
    -> bool;
}

/// Semver-based version matcher for Cargo and npm.
///
/// Uses the semver crate to match version requirements.
/// Handles caret (^) and tilde (~) requirements according to semver semantics.
#[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();

        // Parse the latest version
        let latest_ver = match latest.parse::<Version>() {
            Ok(v) => v,
            Err(_) => return requirement == latest,
        };

        // Try to parse as a semver requirement (handles ^, ~, =, etc.)
        if let Ok(req) = requirement.parse::<VersionReq>() {
            return req.matches(&latest_ver);
        }

        // If not a valid requirement, try treating it as a caret requirement
        // (Cargo's default: "1.0" means "^1.0")
        if let Ok(req) = format!("^{}", requirement).parse::<VersionReq>() {
            return req.matches(&latest_ver);
        }

        // Fallback: string comparison
        requirement == latest
    }
}

/// PEP 440 version matcher for PyPI dependencies.
///
/// Implements major version comparison strategy:
/// - For versions >= 1.0: compares major version only
/// - For versions 0.x: compares major and minor version
///
/// This matches the typical Python ecosystem convention where breaking
/// changes happen on major version bumps (or minor bumps for 0.x versions).
#[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();

        // Parse the latest version (normalize to three parts if needed)
        let latest_ver = match normalize_and_parse_version(latest) {
            Some(v) => v,
            None => return requirement == latest,
        };

        // Extract the minimum version from the requirement
        // Common patterns: ">=1.0", ">=1.0,<2.0", "~=1.0", "==1.0"
        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,
        };

        // Check if major versions match (for major version 0, also check minor)
        if min_ver.major == 0 {
            // For 0.x versions, both major and minor must match
            min_ver.major == latest_ver.major && min_ver.minor == latest_ver.minor
        } else {
            // For 1.x+, just major version must match
            min_ver.major == latest_ver.major
        }
    }
}

/// Normalize a version string and parse it as semver.
///
/// Adds missing patch version if needed (e.g., "8.0" → "8.0.0").
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::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");
/// ```
pub fn normalize_and_parse_version(version: &str) -> Option<Version> {
    // Try parsing directly first
    if let Ok(v) = version.parse::<Version>() {
        return Some(v);
    }

    // Count dots to see if we need to add patch version
    let dot_count = version.chars().filter(|&c| c == '.').count();

    let normalized = match dot_count {
        0 => format!("{}.0.0", version), // "8" → "8.0.0"
        1 => format!("{}.0", version),   // "8.0" → "8.0.0"
        _ => version.to_string(),
    };

    normalized.parse::<Version>().ok()
}

/// Extract the minimum version number from a PEP 440 version specifier.
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::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()));
/// ```
pub fn extract_pypi_min_version(version_req: &str) -> Option<String> {
    // Split by comma and look for >= or ~= or == specifiers
    for part in version_req.split(',') {
        let trimmed = part.trim();

        // Handle different operators
        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('>') {
            // > means strictly greater, but we use this as approximation
            return Some(ver.trim().to_string());
        }
    }

    // If no operator found, try parsing the whole string as a version
    // (handles Poetry's "^1.0" style by stripping the ^)
    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
}

/// Collapses whitespace between a range operator (`>=`, `<=`, `>`, `<`) and its version
/// number, e.g. `">= 1.0 < 2.0"` becomes `">=1.0 <2.0"`.
///
/// Several ecosystem requirement grammars (Dart's pubspec constraints, Composer's version
/// constraints) accept a space after a range operator, but the ecosystem's own AND-splitting
/// logic (splitting a requirement on whitespace to get individual clauses) would otherwise
/// treat the operator and its version as separate clauses.
///
/// Borrows `requirement` unchanged when there is no spaced operator to collapse (the common
/// case) instead of always allocating — callers that check many candidate versions against
/// the same requirement should normalize once and reuse the result rather than re-normalizing
/// per candidate.
///
/// # Examples
///
/// ```
/// # use deps_core::version_matcher::normalize_operator_spacing;
/// assert_eq!(normalize_operator_spacing(">= 1.0 < 2.0"), ">=1.0 <2.0");
/// assert_eq!(normalize_operator_spacing(">=1.0 <2.0"), ">=1.0 <2.0");
/// ```
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)
}

/// Reports whether `requirement` contains a `>`/`<`/`>=`/`<=` operator immediately followed
/// by whitespace, i.e. whether [`normalize_operator_spacing`] would need to allocate. Pure
/// scan, no allocation, so the common no-op case stays cheap.
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;
        // Latest version satisfies the requirement (up-to-date)
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("1.0.0"),
            &ConcreteVersion::new("1.0.5")
        )); // ^1.0.0 allows 1.0.5
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("^1.0.0"),
            &ConcreteVersion::new("1.5.0")
        )); // ^1.0.0 allows 1.5.0
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new("0.1"),
            &ConcreteVersion::new("0.1.83")
        )); // ^0.1 allows 0.1.83
        assert!(
            matcher
                .is_latest_satisfying(&ConcreteVersion::new("1"), &ConcreteVersion::new("1.5.0"))
        ); // ^1 allows 1.5.0
    }

    #[test]
    fn test_semver_matcher_incompatible_versions() {
        let matcher = SemverMatcher;
        // Latest version doesn't satisfy requirement (new major available)
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new("1.0.0"),
            &ConcreteVersion::new("2.0.0")
        )); // 2.0.0 breaks ^1.0.0
        assert!(
            !matcher
                .is_latest_satisfying(&ConcreteVersion::new("0.1"), &ConcreteVersion::new("0.2.0"))
        ); // 0.2.0 breaks ^0.1
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new("~1.0.0"),
            &ConcreteVersion::new("1.1.0")
        )); // ~1.0.0 doesn't allow 1.1.0
    }

    #[test]
    fn test_pep440_matcher_same_major() {
        let matcher = Pep440Matcher;
        // Same major version = up to date
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=8.0"),
            &ConcreteVersion::new("8.3.5")
        )); // 8.x matches 8.x
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0"),
            &ConcreteVersion::new("1.5.0")
        )); // 1.x matches 1.x
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0,<2.0"),
            &ConcreteVersion::new("1.9.0")
        )); // constrained but same major
    }

    #[test]
    fn test_pep440_matcher_new_major() {
        let matcher = Pep440Matcher;
        // New major version available = needs update
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=8.0"),
            &ConcreteVersion::new("9.0.2")
        )); // 8.x vs 9.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=1.0"),
            &ConcreteVersion::new("2.0.0")
        )); // 1.x vs 2.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=4.0,<8.0"),
            &ConcreteVersion::new("8.0.0")
        )); // 4.x vs 8.x
    }

    #[test]
    fn test_pep440_matcher_zero_version() {
        let matcher = Pep440Matcher;
        // For 0.x versions, minor must also match
        assert!(matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=0.8"),
            &ConcreteVersion::new("0.8.5")
        )); // 0.8.x matches 0.8.x
        assert!(!matcher.is_latest_satisfying(
            &ConcreteVersion::new(">=0.8"),
            &ConcreteVersion::new("0.9.0")
        )); // 0.8.x vs 0.9.x
    }

    #[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())); // Poetry style
        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());
    }
}