Skip to main content

lex_syntax/
semver.rs

1//! Minimal semver **constraint** matching for `{ registry = …, version =
2//! "…" }` dependency resolution (#893).
3//!
4//! Not a full semver implementation — no pre-release or build-metadata
5//! ordering — but enough to resolve `^`, `~`, exact, and comparator
6//! (`>= > <= <`) constraints against a set of published
7//! `MAJOR.MINOR.PATCH` release versions. Resolution is **deterministic**:
8//! [`best_match`] always returns the *highest* satisfying version, so a
9//! `lex.lock` pins a reproducible choice.
10//!
11//! Caret/tilde follow Cargo's rules (the left-most non-zero component is
12//! the "fixed" one for `^`; `~` fixes down to the last given component).
13
14/// A `(major, minor, patch)` version.
15pub type Version = (u64, u64, u64);
16
17/// Parse a full `MAJOR.MINOR.PATCH` (a leading `v` allowed). Exactly
18/// three components, unlike [`parse_partial`].
19pub fn parse_exact(v: &str) -> Option<Version> {
20    let (ver, given) = parse_partial(v)?;
21    if given == 3 {
22        Some(ver)
23    } else {
24        None
25    }
26}
27
28/// Parse `MAJOR[.MINOR[.PATCH]]` (leading `v` allowed); omitted
29/// components default to 0. Returns the version and how many components
30/// were actually written (1..=3) — caret/tilde need that.
31fn parse_partial(s: &str) -> Option<(Version, u8)> {
32    let s = s.trim().trim_start_matches('v').trim();
33    if s.is_empty() {
34        return None;
35    }
36    let mut parts = s.split('.');
37    let major: u64 = parts.next()?.trim().parse().ok()?;
38    let (minor, has_minor) = match parts.next() {
39        Some(m) => (m.trim().parse().ok()?, true),
40        None => (0, false),
41    };
42    let (patch, has_patch) = match parts.next() {
43        Some(p) => (p.trim().parse().ok()?, true),
44        None => (0, false),
45    };
46    if parts.next().is_some() {
47        return None; // more than three components
48    }
49    Some(((major, minor, patch), 1 + has_minor as u8 + has_patch as u8))
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53enum Cmp {
54    Ge,
55    Gt,
56    Le,
57    Lt,
58}
59
60#[derive(Debug, Clone)]
61enum Constraint {
62    /// `*`, `latest`, or empty — any released version.
63    Any,
64    /// `1.2.3` — exactly this version.
65    Exact(Version),
66    /// `^X.Y.Z` — `>= v` and `< caret_upper(v, given)`.
67    Caret(Version, Version),
68    /// `~X.Y.Z` — `>= v` and `< tilde_upper(v, given)`.
69    Tilde(Version, Version),
70    /// A single comparator, e.g. `>=1.0`.
71    Compare(Cmp, Version),
72}
73
74/// The exclusive upper bound of a caret constraint: increment the
75/// left-most non-zero component (rest zeroed); if every given component
76/// is zero, increment the last *given* component.
77fn caret_upper((maj, min, pat): Version, given: u8) -> Version {
78    if maj > 0 {
79        (maj + 1, 0, 0)
80    } else if min > 0 {
81        (0, min + 1, 0)
82    } else if pat > 0 {
83        (0, 0, pat + 1)
84    } else {
85        match given {
86            1 => (1, 0, 0),
87            2 => (0, 1, 0),
88            _ => (0, 0, 1),
89        }
90    }
91}
92
93/// The exclusive upper bound of a tilde constraint: minor given ⇒ fix the
94/// minor (`< maj.(min+1).0`); only major given ⇒ fix the major.
95fn tilde_upper((maj, min, _pat): Version, given: u8) -> Version {
96    if given >= 2 {
97        (maj, min + 1, 0)
98    } else {
99        (maj + 1, 0, 0)
100    }
101}
102
103fn parse_constraint(s: &str) -> Option<Constraint> {
104    let s = s.trim();
105    if s.is_empty() || s == "*" || s.eq_ignore_ascii_case("latest") {
106        return Some(Constraint::Any);
107    }
108    if let Some(rest) = s.strip_prefix('^') {
109        let (v, given) = parse_partial(rest)?;
110        return Some(Constraint::Caret(v, caret_upper(v, given)));
111    }
112    if let Some(rest) = s.strip_prefix('~') {
113        let (v, given) = parse_partial(rest)?;
114        return Some(Constraint::Tilde(v, tilde_upper(v, given)));
115    }
116    for (pfx, cmp) in [(">=", Cmp::Ge), ("<=", Cmp::Le), (">", Cmp::Gt), ("<", Cmp::Lt)] {
117        if let Some(rest) = s.strip_prefix(pfx) {
118            return Some(Constraint::Compare(cmp, parse_partial(rest)?.0));
119        }
120    }
121    // A bare version with `=` is exact; a bare `1.2` is treated as `^1.2`
122    // (Cargo's default), which is the friendlier "compatible" reading.
123    if let Some(rest) = s.strip_prefix('=') {
124        return Some(Constraint::Exact(parse_partial(rest)?.0));
125    }
126    let (v, given) = parse_partial(s)?;
127    if given == 3 {
128        Some(Constraint::Exact(v))
129    } else {
130        Some(Constraint::Caret(v, caret_upper(v, given)))
131    }
132}
133
134/// Does `version` (a `MAJOR.MINOR.PATCH` string) satisfy `constraint`?
135/// An unparseable version never matches; an unparseable constraint
136/// matches nothing (the caller should surface "cannot resolve" rather
137/// than guess).
138pub fn satisfies(constraint: &str, version: &str) -> bool {
139    let (Some(c), Some(v)) = (parse_constraint(constraint), parse_exact(version)) else {
140        return false;
141    };
142    match c {
143        Constraint::Any => true,
144        Constraint::Exact(e) => v == e,
145        Constraint::Caret(lo, hi) | Constraint::Tilde(lo, hi) => v >= lo && v < hi,
146        Constraint::Compare(Cmp::Ge, r) => v >= r,
147        Constraint::Compare(Cmp::Gt, r) => v > r,
148        Constraint::Compare(Cmp::Le, r) => v <= r,
149        Constraint::Compare(Cmp::Lt, r) => v < r,
150    }
151}
152
153/// The highest of `versions` that satisfies `constraint`, or `None` if
154/// none do. Deterministic — the resolver records this exact version in
155/// the lock file.
156pub fn best_match<'a>(constraint: &str, versions: &'a [String]) -> Option<&'a str> {
157    versions
158        .iter()
159        .filter(|v| satisfies(constraint, v))
160        .max_by_key(|v| parse_exact(v).unwrap_or((0, 0, 0)))
161        .map(|s| s.as_str())
162}
163
164/// The kind of version increment between two releases, ordered
165/// `Patch < Minor < Major`.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
167pub enum Bump {
168    Patch,
169    Minor,
170    Major,
171}
172
173/// The bump from `prev` to `new` (both `MAJOR.MINOR.PATCH`). `None` if either
174/// is unparseable or `new` is not strictly greater than `prev` — the caller
175/// decides what a non-forward release means (the version-bump gate skips it).
176pub fn bump_between(prev: &str, new: &str) -> Option<Bump> {
177    let p = parse_exact(prev)?;
178    let n = parse_exact(new)?;
179    if n <= p {
180        return None;
181    }
182    if n.0 > p.0 {
183        Some(Bump::Major)
184    } else if n.1 > p.1 {
185        Some(Bump::Minor)
186    } else {
187        Some(Bump::Patch)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn bumps() {
197        assert_eq!(bump_between("1.0.0", "2.0.0"), Some(Bump::Major));
198        assert_eq!(bump_between("1.2.0", "1.3.0"), Some(Bump::Minor));
199        assert_eq!(bump_between("1.2.3", "1.2.4"), Some(Bump::Patch));
200        // major bump beats a simultaneous minor/patch change
201        assert_eq!(bump_between("1.2.3", "2.0.0"), Some(Bump::Major));
202        // not strictly greater → None
203        assert_eq!(bump_between("1.2.3", "1.2.3"), None);
204        assert_eq!(bump_between("1.2.3", "1.2.0"), None);
205        assert_eq!(bump_between("1.2.3", "nope"), None);
206        // ordering
207        assert!(Bump::Major > Bump::Minor && Bump::Minor > Bump::Patch);
208    }
209
210    #[test]
211    fn exact() {
212        assert!(satisfies("1.2.3", "1.2.3"));
213        assert!(satisfies("=1.2.3", "1.2.3"));
214        assert!(!satisfies("1.2.3", "1.2.4"));
215    }
216
217    #[test]
218    fn caret_major() {
219        assert!(satisfies("^1.2.3", "1.2.3"));
220        assert!(satisfies("^1.2.3", "1.9.0"));
221        assert!(!satisfies("^1.2.3", "2.0.0"));
222        assert!(!satisfies("^1.2.3", "1.2.2"));
223        // partial: ^1.2 == >=1.2.0 <2.0.0
224        assert!(satisfies("^1.2", "1.2.0"));
225        assert!(satisfies("^1.2", "1.5.9"));
226        assert!(!satisfies("^1.2", "2.0.0"));
227        assert!(!satisfies("^1.2", "1.1.9"));
228        // ^1 == >=1.0.0 <2.0.0
229        assert!(satisfies("^1", "1.0.0") && satisfies("^1", "1.9.9") && !satisfies("^1", "2.0.0"));
230        // bare 1.2 defaults to ^1.2
231        assert!(satisfies("1.2", "1.4.0") && !satisfies("1.2", "2.0.0"));
232    }
233
234    #[test]
235    fn caret_zero() {
236        // ^0.2.3 == >=0.2.3 <0.3.0
237        assert!(satisfies("^0.2.3", "0.2.3") && satisfies("^0.2.3", "0.2.9"));
238        assert!(!satisfies("^0.2.3", "0.3.0") && !satisfies("^0.2.3", "0.2.2"));
239        // ^0.0.3 == >=0.0.3 <0.0.4
240        assert!(satisfies("^0.0.3", "0.0.3") && !satisfies("^0.0.3", "0.0.4"));
241        // ^0.2 == >=0.2.0 <0.3.0
242        assert!(satisfies("^0.2", "0.2.5") && !satisfies("^0.2", "0.3.0"));
243        // ^0 == >=0.0.0 <1.0.0
244        assert!(satisfies("^0", "0.9.9") && !satisfies("^0", "1.0.0"));
245    }
246
247    #[test]
248    fn tilde() {
249        // ~1.2.3 == >=1.2.3 <1.3.0
250        assert!(satisfies("~1.2.3", "1.2.9") && !satisfies("~1.2.3", "1.3.0"));
251        // ~1.2 == >=1.2.0 <1.3.0
252        assert!(satisfies("~1.2", "1.2.9") && !satisfies("~1.2", "1.3.0"));
253        // ~1 == >=1.0.0 <2.0.0
254        assert!(satisfies("~1", "1.9.9") && !satisfies("~1", "2.0.0"));
255    }
256
257    #[test]
258    fn comparators_and_any() {
259        assert!(satisfies(">=1.0", "1.0.0") && satisfies(">=1.0", "9.9.9") && !satisfies(">=1.0", "0.9.9"));
260        assert!(satisfies(">1.0", "1.0.1") && !satisfies(">1.0", "1.0.0"));
261        assert!(satisfies("<2.0", "1.9.9") && !satisfies("<2.0", "2.0.0"));
262        assert!(satisfies("<=1.5", "1.5.0") && !satisfies("<=1.5", "1.5.1"));
263        for c in ["*", "latest", ""] {
264            assert!(satisfies(c, "3.1.4"), "{c} should match anything");
265        }
266    }
267
268    #[test]
269    fn best_match_picks_highest() {
270        let vs: Vec<String> = ["1.0.0", "1.2.0", "1.4.9", "2.0.0", "1.4.2"]
271            .iter().map(|s| s.to_string()).collect();
272        assert_eq!(best_match("^1.2", &vs), Some("1.4.9"));
273        assert_eq!(best_match("~1.4", &vs), Some("1.4.9"));
274        assert_eq!(best_match(">=1.0", &vs), Some("2.0.0"));
275        assert_eq!(best_match("^3", &vs), None);
276        assert_eq!(best_match("1.4.2", &vs), Some("1.4.2"));
277    }
278
279    #[test]
280    fn unparseable_never_matches() {
281        assert!(!satisfies("^1.2.3", "not-a-version"));
282        assert!(!satisfies("garbage", "1.2.3"));
283    }
284}