Skip to main content

mnm_core/
version_match.rs

1//! Version-interval parsing and match classification (version-provenance spec §2).
2//!
3//! The request side of `version_satisfies` is a concrete version OR a semver
4//! range; the declared side (`provenance.*.version_constraint`) is a
5//! `semver::VersionReq`. The `semver` crate's grammar has no OR operator, so
6//! every requirement is a conjunction of comparators — one contiguous interval.
7
8use semver::{Comparator, Op, Version, VersionReq};
9
10use crate::scoring::parse_version;
11
12/// One interval bound: a version plus whether the bound itself is included.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Bound {
15    /// The bounding version.
16    pub version: Version,
17    /// `true` for `>=`/`<=`-style bounds, `false` for `>`/`<`.
18    pub inclusive: bool,
19}
20
21/// A contiguous version interval. `None` bounds are unbounded sides.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct VersionInterval {
24    /// Lower bound (`None` = unbounded below).
25    pub lo: Option<Bound>,
26    /// Upper bound (`None` = unbounded above).
27    pub hi: Option<Bound>,
28}
29
30/// A parsed request-side `version_satisfies` value.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum RequestedVersion {
33    /// A concrete version (bare `"0.31"`, `"v1.4.2"` — via [`parse_version`]).
34    Concrete(Version),
35    /// An explicit range (`">=0.23"`, `"^1.2"`, `"~1.4.2"`, ...).
36    Range(VersionInterval),
37}
38
39/// Outcome of classifying a request against one declared constraint.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum MatchClass {
42    /// The request satisfies / intersects the declared constraint.
43    Satisfies,
44    /// Disjoint at patch level (after the 0.x role shift); distance in patches.
45    NearMissPatch(u32),
46    /// Disjoint at minor level (after the 0.x role shift); distance in minors.
47    NearMissMinor(u32),
48    /// Disjoint at major level (or 0.x minor / 0.0.x patch): incompatible.
49    Breaking,
50    /// The declared constraint failed to parse — unknowable.
51    Unknown,
52}
53
54/// Parse a request-side `version_satisfies` string into a [`RequestedVersion`].
55///
56/// Concrete first: bare versions stay concrete (spec decision 5), else a semver
57/// range desugared to an interval. `None` = neither form parses, or the range
58/// is empty.
59#[must_use]
60pub fn parse_request(raw: &str) -> Option<RequestedVersion> {
61    // Concrete first: bare versions stay concrete (spec decision 5).
62    if let Some(v) = parse_version(raw) {
63        // parse_version accepts bare numeric cores only when no operator
64        // characters are present — but it also strips a leading 'v'. Reject the
65        // concrete path when the string contains range syntax so e.g. "<1.0"
66        // (whose numeric core parses) is not misread. parse_version already
67        // returns None for those (non-numeric first char), so this is direct:
68        return Some(RequestedVersion::Concrete(v));
69    }
70    let parsed = VersionReq::parse(raw.trim()).ok()?;
71    let interval = VersionInterval::from_req(&parsed)?;
72    if interval.is_empty() {
73        return None;
74    }
75    Some(RequestedVersion::Range(interval))
76}
77
78/// Classify a parsed request against a declared constraint string.
79/// `declared = None` (a matching-name target with no constraint) ⇒ Satisfies.
80#[must_use]
81pub fn classify(requested: &RequestedVersion, declared: Option<&str>) -> MatchClass {
82    let Some(constraint) = declared else {
83        return MatchClass::Satisfies; // unconstrained target applies to everything
84    };
85    let Ok(declared_req) = VersionReq::parse(constraint) else {
86        return MatchClass::Unknown;
87    };
88    // Satisfies check uses the oracle directly for concrete requests so the
89    // pre-existing semantics (incl. pre-release subtleties) are unchanged.
90    match requested {
91        RequestedVersion::Concrete(v) => {
92            if declared_req.matches(v) {
93                return MatchClass::Satisfies;
94            }
95        }
96        RequestedVersion::Range(ri) => {
97            let Some(di) = VersionInterval::from_req(&declared_req) else {
98                return MatchClass::Unknown;
99            };
100            if ri.intersects(&di) {
101                return MatchClass::Satisfies;
102            }
103        }
104    }
105    let Some(di) = VersionInterval::from_req(&declared_req) else {
106        return MatchClass::Unknown;
107    };
108    let ri = match requested {
109        RequestedVersion::Concrete(v) => VersionInterval::point(v.clone()),
110        RequestedVersion::Range(r) => r.clone(),
111    };
112    // Disjoint: compare the nearest actual MEMBERS across the gap (exclusive
113    // bounds step to their adjacent representable version — a `<2.0.0` ceiling's
114    // nearest member is 1.x, so `>=2.0` vs `^1.4` is Breaking, not a near-miss).
115    let Some((a, b)) = gap_pair(&ri, &di) else {
116        return MatchClass::Unknown; // defensive; disjoint intervals have a gap side
117    };
118    mismatch_class(&a, &b)
119}
120
121/// The nearest actual member of an interval on the gap side. Inclusive bounds
122/// are themselves members; exclusive bounds step one version inward.
123fn representative(b: &Bound, is_upper: bool) -> Version {
124    if b.inclusive {
125        return b.version.clone();
126    }
127    let v = &b.version;
128    if is_upper {
129        // Step down at the finest non-zero component.
130        if v.patch > 0 {
131            Version::new(v.major, v.minor, v.patch - 1)
132        } else if v.minor > 0 {
133            Version::new(v.major, v.minor - 1, u64::MAX)
134        } else if v.major > 0 {
135            Version::new(v.major - 1, u64::MAX, u64::MAX)
136        } else {
137            v.clone() // `<0.0.0` — empty interval; parse_request rejects it
138        }
139    } else {
140        // Step up: the next representable version.
141        Version::new(v.major, v.minor, v.patch + 1)
142    }
143}
144
145/// Nearest-member pair `(request_side, declared_side)` across the gap.
146fn gap_pair(req: &VersionInterval, decl: &VersionInterval) -> Option<(Version, Version)> {
147    // Request entirely below declared: request's ceiling vs declared's floor.
148    if let (Some(r), Some(d)) = (&req.hi, &decl.lo) {
149        if r.version <= d.version {
150            return Some((representative(r, true), representative(d, false)));
151        }
152    }
153    // Request entirely above declared: request's floor vs declared's ceiling.
154    if let (Some(r), Some(d)) = (&req.lo, &decl.hi) {
155        if r.version >= d.version {
156            return Some((representative(r, false), representative(d, true)));
157        }
158    }
159    None
160}
161
162/// The 0.x role shift (spec decision 6): major==0 → minor acts as major
163/// (Breaking), patch acts as minor; 0.0.x → every mismatch is Breaking.
164fn mismatch_class(a: &Version, b: &Version) -> MatchClass {
165    #[allow(clippy::cast_possible_truncation)]
166    fn dist(x: u64, y: u64) -> u32 {
167        x.abs_diff(y).min(u64::from(u32::MAX)) as u32
168    }
169    if a == b {
170        // Defensive: representatives of valid disjoint intervals differ.
171        return MatchClass::NearMissPatch(1);
172    }
173    if a.major != b.major {
174        return MatchClass::Breaking;
175    }
176    if a.major == 0 {
177        if a.minor != b.minor || a.minor == 0 {
178            return MatchClass::Breaking; // 0.x minor mismatch / 0.0.x anything
179        }
180        return MatchClass::NearMissMinor(dist(a.patch, b.patch).max(1));
181    }
182    if a.minor != b.minor {
183        return MatchClass::NearMissMinor(dist(a.minor, b.minor));
184    }
185    MatchClass::NearMissPatch(dist(a.patch, b.patch).max(1))
186}
187
188/// Rank for "best class wins" across elements: lower is better.
189#[must_use]
190pub const fn class_rank(c: &MatchClass) -> (u8, u32) {
191    match c {
192        MatchClass::Satisfies => (0, 0),
193        MatchClass::NearMissPatch(d) => (1, *d),
194        MatchClass::NearMissMinor(d) => (2, *d),
195        MatchClass::Unknown => (3, 0),
196        MatchClass::Breaking => (4, 0),
197    }
198}
199
200impl VersionInterval {
201    /// The interval containing every version.
202    #[must_use]
203    pub const fn unbounded() -> Self {
204        Self { lo: None, hi: None }
205    }
206
207    /// Degenerate single-point interval `[v, v]`.
208    #[must_use]
209    pub fn point(v: Version) -> Self {
210        Self {
211            lo: Some(Bound {
212                version: v.clone(),
213                inclusive: true,
214            }),
215            hi: Some(Bound { version: v, inclusive: true }),
216        }
217    }
218
219    /// Desugar a `VersionReq` (conjunction of comparators) to an interval.
220    /// `None` when a comparator op is unsupported (future semver ops).
221    #[must_use]
222    pub fn from_req(req: &VersionReq) -> Option<Self> {
223        let mut iv = Self::unbounded();
224        for c in &req.comparators {
225            iv = iv.intersect(&comparator_interval(c)?);
226        }
227        Some(iv)
228    }
229
230    /// Whether `v` lies inside the interval.
231    #[must_use]
232    pub fn contains(&self, v: &Version) -> bool {
233        if let Some(lo) = &self.lo {
234            if *v < lo.version || (*v == lo.version && !lo.inclusive) {
235                return false;
236            }
237        }
238        if let Some(hi) = &self.hi {
239            if *v > hi.version || (*v == hi.version && !hi.inclusive) {
240                return false;
241            }
242        }
243        true
244    }
245
246    /// Whether two intervals share at least one version.
247    #[must_use]
248    pub fn intersects(&self, other: &Self) -> bool {
249        !self.intersect(other).is_empty()
250    }
251
252    /// Whether the interval contains no versions (contradictory comparators).
253    #[must_use]
254    pub fn is_empty(&self) -> bool {
255        match (&self.lo, &self.hi) {
256            (Some(lo), Some(hi)) => {
257                lo.version > hi.version
258                    || (lo.version == hi.version && !(lo.inclusive && hi.inclusive))
259            }
260            _ => false,
261        }
262    }
263
264    /// Tightest interval inside both: max of lower bounds, min of upper bounds.
265    fn intersect(&self, other: &Self) -> Self {
266        let lo = match (&self.lo, &other.lo) {
267            (Some(a), Some(b)) => {
268                Some(if (b.version.clone(), !b.inclusive) > (a.version.clone(), !a.inclusive) {
269                    b.clone()
270                } else {
271                    a.clone()
272                })
273            }
274            (Some(a), None) => Some(a.clone()),
275            (None, b) => b.clone(),
276        };
277        let hi = match (&self.hi, &other.hi) {
278            (Some(a), Some(b)) => {
279                Some(if (b.version.clone(), b.inclusive) < (a.version.clone(), a.inclusive) {
280                    b.clone()
281                } else {
282                    a.clone()
283                })
284            }
285            (Some(a), None) => Some(a.clone()),
286            (None, b) => b.clone(),
287        };
288        Self { lo, hi }
289    }
290}
291
292/// Desugar one comparator per cargo rules. `None` for ops this version of the
293/// `semver` crate may add in future (`Op` is `non_exhaustive`).
294fn comparator_interval(c: &Comparator) -> Option<VersionInterval> {
295    let maj = c.major;
296    let min = c.minor;
297    let pat = c.patch;
298    let base = Version::new(maj, min.unwrap_or(0), pat.unwrap_or(0));
299    let lo_incl = |v: Version| Some(Bound { version: v, inclusive: true });
300    let hi_excl = |v: Version| Some(Bound { version: v, inclusive: false });
301    let next_major = Version::new(maj + 1, 0, 0);
302    let next_minor = |j: u64| Version::new(maj, j + 1, 0);
303    Some(match c.op {
304        Op::Exact => match (min, pat) {
305            (Some(_), Some(_)) => VersionInterval::point(base),
306            (Some(j), None) => VersionInterval {
307                lo: lo_incl(base),
308                hi: hi_excl(next_minor(j)),
309            },
310            (None, _) => VersionInterval {
311                lo: lo_incl(base),
312                hi: hi_excl(next_major),
313            },
314        },
315        Op::Greater => match (min, pat) {
316            (Some(_), Some(_)) => VersionInterval {
317                lo: Some(Bound {
318                    version: base,
319                    inclusive: false,
320                }),
321                hi: None,
322            },
323            (Some(j), None) => VersionInterval {
324                lo: lo_incl(next_minor(j)),
325                hi: None,
326            },
327            (None, _) => VersionInterval {
328                lo: lo_incl(next_major),
329                hi: None,
330            },
331        },
332        Op::GreaterEq => VersionInterval { lo: lo_incl(base), hi: None },
333        Op::Less => VersionInterval { lo: None, hi: hi_excl(base) },
334        Op::LessEq => match (min, pat) {
335            (Some(_), Some(_)) => VersionInterval {
336                lo: None,
337                hi: Some(Bound { version: base, inclusive: true }),
338            },
339            (Some(j), None) => VersionInterval {
340                lo: None,
341                hi: hi_excl(next_minor(j)),
342            },
343            (None, _) => VersionInterval {
344                lo: None,
345                hi: hi_excl(next_major),
346            },
347        },
348        // `~` and `*`/`.x` desugar identically for the partial forms we accept.
349        Op::Tilde | Op::Wildcard => match (min, pat) {
350            (Some(j), _) => VersionInterval {
351                lo: lo_incl(base),
352                hi: hi_excl(next_minor(j)),
353            },
354            (None, _) => VersionInterval {
355                lo: lo_incl(base),
356                hi: hi_excl(next_major),
357            },
358        },
359        Op::Caret => {
360            let hi = if maj > 0 {
361                next_major
362            } else if min.unwrap_or(0) > 0 {
363                next_minor(min.unwrap_or(0))
364            } else if pat.is_some() {
365                Version::new(0, 0, pat.unwrap_or(0) + 1)
366            } else {
367                // ^0 == [0.0.0, 1.0.0)
368                Version::new(1, 0, 0)
369            };
370            VersionInterval {
371                lo: lo_incl(base),
372                hi: hi_excl(hi),
373            }
374        }
375        _ => return None,
376    })
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use proptest::prelude::*;
383
384    fn v(s: &str) -> Version {
385        parse_version(s).unwrap()
386    }
387    fn req(s: &str) -> RequestedVersion {
388        parse_request(s).unwrap()
389    }
390
391    #[test]
392    fn parse_request_bare_is_concrete() {
393        assert_eq!(req("0.31"), RequestedVersion::Concrete(v("0.31")));
394        assert_eq!(req("v1.4.2"), RequestedVersion::Concrete(v("1.4.2")));
395    }
396
397    #[test]
398    fn parse_request_operators_are_ranges() {
399        for s in [">=0.23", "^1.2", "~1.4.2", ">=1.0, <2.0", "1.2.*"] {
400            assert!(matches!(parse_request(s), Some(RequestedVersion::Range(_))), "{s}");
401        }
402    }
403
404    #[test]
405    fn parse_request_rejects_garbage_and_empty_ranges() {
406        assert_eq!(parse_request("not-a-version"), None);
407        // contradictory conjunction → empty interval → None
408        assert_eq!(parse_request(">=2.0, <1.0"), None);
409    }
410
411    #[test]
412    fn classify_concrete_against_range() {
413        // matches: oracle semantics unchanged from today
414        assert_eq!(classify(&req("0.31"), Some(">=0.23")), MatchClass::Satisfies);
415        // 0.x: minor mismatch is breaking (role shift)
416        assert_eq!(classify(&req("0.10"), Some(">=0.23")), MatchClass::Breaking);
417        // 0.x patch mismatch scales as minor: declared <0.23.5 covers ≤0.23.4,
418        // so the nearest member is 0.23.4 → distance 5
419        assert_eq!(
420            classify(&req("0.23.9"), Some(">=0.23.0, <0.23.5")),
421            MatchClass::NearMissMinor(5)
422        );
423        // major>0: minor mismatch scales; ^1.4 = [1.4.0, 2.0.0) so 1.6 satisfies
424        assert_eq!(classify(&req("1.6.0"), Some("^1.4")), MatchClass::Satisfies);
425        // declared <1.5 covers 1.4.x: request 1.6 → minor distance 2 (1.6 vs 1.4)
426        assert_eq!(classify(&req("1.6.0"), Some(">=1.4, <1.5")), MatchClass::NearMissMinor(2));
427        // patch-level miss: declared <1.4.3 covers ≤1.4.2, so the nearest member
428        // is 1.4.2; request 1.4.5 → patch distance 3 (same stepping rule as the
429        // 0.23.9 vs <0.23.5 case above: exclusive ceiling steps inward first).
430        assert_eq!(classify(&req("1.4.5"), Some(">=1.4.0, <1.4.3")), MatchClass::NearMissPatch(3));
431        // major mismatch always breaking
432        assert_eq!(classify(&req("2.0.0"), Some("^1.4")), MatchClass::Breaking);
433        // 0.0.x: every mismatch breaking
434        assert_eq!(classify(&req("0.0.9"), Some("=0.0.3")), MatchClass::Breaking);
435    }
436
437    #[test]
438    fn classify_edge_cases() {
439        // declared None = unconstrained target → Satisfies
440        assert_eq!(classify(&req("0.31"), None), MatchClass::Satisfies);
441        // unparseable declared → Unknown
442        assert_eq!(classify(&req("0.31"), Some("banana")), MatchClass::Unknown);
443        // request sits exactly on an exclusive ceiling: declared covers 1.4.x,
444        // so 1.5.0 is one minor past the nearest member → NearMissMinor(1)
445        assert_eq!(classify(&req("1.5.0"), Some(">=1.4.0, <1.5.0")), MatchClass::NearMissMinor(1));
446        // range request vs range declared: intersection
447        assert_eq!(classify(&req("^1.4"), Some(">=1.0, <2.0")), MatchClass::Satisfies);
448        assert_eq!(classify(&req(">=2.0"), Some("^1.4")), MatchClass::Breaking);
449    }
450
451    #[test]
452    fn class_rank_orders_best_first() {
453        let mut classes = vec![
454            MatchClass::Breaking,
455            MatchClass::NearMissMinor(2),
456            MatchClass::Satisfies,
457            MatchClass::Unknown,
458            MatchClass::NearMissPatch(1),
459            MatchClass::NearMissMinor(1),
460        ];
461        classes.sort_by_key(class_rank);
462        assert_eq!(
463            classes,
464            vec![
465                MatchClass::Satisfies,
466                MatchClass::NearMissPatch(1),
467                MatchClass::NearMissMinor(1),
468                MatchClass::NearMissMinor(2),
469                MatchClass::Unknown,
470                MatchClass::Breaking,
471            ]
472        );
473    }
474
475    proptest! {
476        /// Interval desugaring agrees with `VersionReq::matches` (the oracle)
477        /// for non-prerelease versions across all comparator ops.
478        #[test]
479        fn interval_agrees_with_matches_oracle(
480            major in 0u64..3, minor in 0u64..50, patch in 0u64..50,
481            req_major in 0u64..3, req_minor in 0u64..50, req_patch in 0u64..50,
482            op in prop::sample::select(vec!["=", ">", ">=", "<", "<=", "~", "^"]),
483        ) {
484            let candidate = Version::new(major, minor, patch);
485            let raw = format!("{op}{req_major}.{req_minor}.{req_patch}");
486            let parsed = VersionReq::parse(&raw).unwrap();
487            let interval = VersionInterval::from_req(&parsed).unwrap();
488            prop_assert_eq!(interval.contains(&candidate), parsed.matches(&candidate), "{}", raw);
489        }
490    }
491}