Skip to main content

sbom_model/
versions.rs

1//! version parsing and comparison utilities.
2//!
3//! provides lenient version parsing for SBOM component versions, supporting
4//! semver, dot-separated numeric strings, PEP 440 (Python) versions,
5//! Debian and RPM epoch/revision versions, Maven (Java) versions, and opaque
6//! version strings.
7//!
8//! two entry points parse a version string: [`Version::parse_lenient`] infers
9//! the format from the string alone, and [`Version::parse_for_ecosystem`]
10//! applies the rules of the component's ecosystem. inference cannot separate a
11//! Debian revision from a semver pre-release — `1.2.3-1ubuntu2` and
12//! `1.0.0-alpha.1` are the same shape, ordered in opposite directions — so
13//! callers that know the ecosystem should pass it.
14
15use std::cmp::Ordering;
16
17/// parsed version representation for lenient comparison.
18///
19/// covers the common version formats found in SBOMs:
20/// - standard semver (possibly with `v` prefix or fewer than three parts)
21/// - dot-separated numeric (e.g., date-based `2024.01.15` or four-part `1.2.3.4`)
22/// - PEP 440 pre/post/dev releases and epochs (dominant in Python SBOMs)
23/// - Debian `epoch:upstream-revision` and RPM `epoch:version-release` (dominant
24///   in OS/container SBOMs)
25/// - Maven versions, whose qualifiers (`1.0-SNAPSHOT`, `2.0-rc1`) look like
26///   semver pre-releases but are ranked by a named order
27/// - opaque strings that cannot be compared
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum Version {
30    /// parseable as semver (with lenient parsing: `v`/`V` prefix stripped,
31    /// one- or two-part versions padded to three parts).
32    Semver(semver::Version),
33    /// dot-separated numeric segments that don't qualify as semver
34    /// (e.g., four-part versions or versions with leading zeros).
35    Numeric(Vec<u64>),
36    /// PEP 440 (Python) version carrying an epoch, pre-release, post-release or
37    /// dev-release segment, ordered per the PEP.
38    Pep440(Pep440),
39    /// Debian-style `epoch:upstream-revision` version, compared with the Debian
40    /// `dpkg` algorithm. a `N!` epoch prefix is accepted too, for strings
41    /// [`Pep440`](Version::Pep440) declines. an absent epoch is `0` and an
42    /// absent revision is the empty string.
43    Deb {
44        epoch: u64,
45        upstream: String,
46        revision: String,
47    },
48    /// RPM `epoch:version-release`, compared with rpm's own `rpmvercmp`
49    /// algorithm, which disagrees with the Debian one on ordinary inputs. an
50    /// absent epoch is `0`; an absent release is the empty string and sorts
51    /// below every release, including `0`. only
52    /// [`parse_for_ecosystem`](Version::parse_for_ecosystem) produces this
53    /// variant — the shape alone does not distinguish an RPM version from a
54    /// Debian one.
55    Rpm {
56        epoch: u64,
57        version: String,
58        release: String,
59    },
60    /// Maven (Java) version, compared with Maven's own version-order algorithm,
61    /// under which `1.0-SNAPSHOT` sorts below `1.0` but `1.0-sp` above it. only
62    /// [`parse_for_ecosystem`](Version::parse_for_ecosystem) produces this
63    /// variant — the shape alone does not distinguish a Maven qualifier from a
64    /// semver pre-release or a Debian revision.
65    Maven(String),
66    /// non-parseable version string where ordering cannot be determined.
67    Opaque(String),
68}
69
70/// a normalized PEP 440 version: `[N!]N(.N)*[{a|b|rc}N][.postN][.devN][+local]`.
71///
72/// spelling aliases are folded to the canonical form during parsing
73/// (`alpha` → `a`, `beta` → `b`, `c`/`pre`/`preview` → `rc`, `rev`/`r` →
74/// `post`), and `-`/`_`/`.` separators are equivalent.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct Pep440 {
77    pub epoch: u64,
78    pub release: Vec<u64>,
79    pub pre: Option<(PreRelease, u64)>,
80    pub post: Option<u64>,
81    pub dev: Option<u64>,
82    pub local: Vec<LocalSegment>,
83}
84
85/// a PEP 440 pre-release kind, in ascending order.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
87pub enum PreRelease {
88    Alpha,
89    Beta,
90    Rc,
91}
92
93/// one dot-separated part of a PEP 440 local version label. the variant order
94/// is the PEP 440 rule: a lexical part sorts before any numeric one.
95#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
96pub enum LocalSegment {
97    Str(String),
98    Num(u64),
99}
100
101impl Version {
102    /// parses a version string leniently.
103    ///
104    /// tries semver first (stripping `v`/`V` prefix and padding one- or
105    /// two-part versions), then dot-separated numeric, then PEP 440, then
106    /// Debian-style epoch/revision versions, then falls back to
107    /// [`Opaque`](Version::Opaque).
108    ///
109    /// the shape alone does not always identify the format; when the
110    /// component's ecosystem is known, prefer
111    /// [`parse_for_ecosystem`](Self::parse_for_ecosystem).
112    ///
113    /// # Examples
114    ///
115    /// ```
116    /// use sbom_model::versions::Version;
117    ///
118    /// assert!(matches!(Version::parse_lenient("1.2.3"), Version::Semver(_)));
119    /// assert!(matches!(Version::parse_lenient("v1.2"), Version::Semver(_)));
120    /// assert!(matches!(Version::parse_lenient("2024.01.15"), Version::Numeric(_)));
121    /// assert!(matches!(Version::parse_lenient("4.2.0rc1"), Version::Pep440(_)));
122    /// assert!(matches!(Version::parse_lenient("2:1.0-3"), Version::Deb { .. }));
123    /// assert!(matches!(Version::parse_lenient("abc"), Version::Opaque(_)));
124    /// ```
125    pub fn parse_lenient(s: &str) -> Self {
126        let stripped = strip_v_prefix(s);
127
128        if let Ok(v) = semver::Version::parse(stripped) {
129            return Version::Semver(v);
130        }
131
132        // try padding: "1.0" -> "1.0.0", "1" -> "1.0.0"
133        let parts: Vec<&str> = stripped.splitn(3, '.').collect();
134        let padded = match parts.len() {
135            1 => Some(format!("{}.0.0", parts[0])),
136            2 => Some(format!("{}.{}.0", parts[0], parts[1])),
137            _ => None,
138        };
139        if let Some(ref padded) = padded {
140            if let Ok(v) = semver::Version::parse(padded) {
141                return Version::Semver(v);
142            }
143        }
144
145        if let Some(segments) = parse_numeric(stripped) {
146            return Version::Numeric(segments);
147        }
148
149        if let Some(pep) = parse_pep440(stripped) {
150            // a plain release has no PEP 440-specific segment; leave its classification alone
151            if pep.epoch > 0 || pep.pre.is_some() || pep.post.is_some() || pep.dev.is_some() {
152                return Version::Pep440(pep);
153            }
154        }
155
156        if let Some(deb) = parse_deb(stripped) {
157            return deb;
158        }
159
160        Version::Opaque(s.to_string())
161    }
162
163    /// parses a version string with the rules of the ecosystem it came from.
164    ///
165    /// `ecosystem` is a purl package type (the value of
166    /// [`Component::ecosystem`](crate::Component::ecosystem)). `None`, or a type
167    /// with no dedicated ruleset, is exactly [`parse_lenient`](Self::parse_lenient).
168    ///
169    /// `deb` versions are read as [`Deb`](Version::Deb) and ordered by the
170    /// `dpkg` algorithm, `rpm` versions as [`Rpm`](Version::Rpm) and ordered by
171    /// rpm's `rpmvercmp`, `maven` versions as [`Maven`](Version::Maven) and
172    /// ordered by Maven's version-order algorithm. a leading `v`/`V` is
173    /// stripped, as [`parse_lenient`](Self::parse_lenient) does; a string that
174    /// is still not a valid version for that ecosystem is
175    /// [`Opaque`](Version::Opaque) rather than being retried as semver.
176    ///
177    /// # Examples
178    ///
179    /// ```
180    /// use std::cmp::Ordering;
181    /// use sbom_model::versions::Version;
182    ///
183    /// // `1ubuntu2` is a Debian revision, not a semver pre-release
184    /// let old = Version::parse_for_ecosystem(Some("deb"), "1.2.3-1ubuntu2");
185    /// let new = Version::parse_for_ecosystem(Some("deb"), "1.2.3-2");
186    /// assert_eq!(old.partial_cmp_lenient(&new), Some(Ordering::Less));
187    ///
188    /// // rpm ranks a numeric segment above an alpha one, dpkg the other way
189    /// let old = Version::parse_for_ecosystem(Some("rpm"), "1.a");
190    /// let new = Version::parse_for_ecosystem(Some("rpm"), "1.1");
191    /// assert_eq!(old.partial_cmp_lenient(&new), Some(Ordering::Less));
192    ///
193    /// // a Maven snapshot precedes its release, and `sp` follows it
194    /// let snapshot = Version::parse_for_ecosystem(Some("maven"), "1.0-SNAPSHOT");
195    /// let release = Version::parse_for_ecosystem(Some("maven"), "1.0");
196    /// let patched = Version::parse_for_ecosystem(Some("maven"), "1.0-sp1");
197    /// assert_eq!(snapshot.partial_cmp_lenient(&release), Some(Ordering::Less));
198    /// assert_eq!(patched.partial_cmp_lenient(&release), Some(Ordering::Greater));
199    ///
200    /// let guessed = Version::parse_for_ecosystem(None, "1.2.3-1ubuntu2");
201    /// assert_eq!(guessed, Version::parse_lenient("1.2.3-1ubuntu2"));
202    /// ```
203    pub fn parse_for_ecosystem(ecosystem: Option<&str>, s: &str) -> Self {
204        match Scheme::for_ecosystem(ecosystem) {
205            Scheme::Infer => Version::parse_lenient(s),
206            Scheme::Deb => parse_deb(s)
207                .or_else(|| parse_deb(strip_v_prefix(s)))
208                .unwrap_or_else(|| Version::Opaque(s.to_string())),
209            Scheme::Rpm => parse_rpm(s)
210                .or_else(|| parse_rpm(strip_v_prefix(s)))
211                .unwrap_or_else(|| Version::Opaque(s.to_string())),
212            Scheme::Maven => parse_maven(s)
213                .or_else(|| parse_maven(strip_v_prefix(s)))
214                .unwrap_or_else(|| Version::Opaque(s.to_string())),
215        }
216    }
217
218    /// orders two versions, returning `None` when the ordering is unknown.
219    ///
220    /// comparison strategy depends on the variant pair:
221    /// - **Semver vs Semver**: semver *precedence* ordering (including
222    ///   pre-release; build metadata is ignored per SemVer §10)
223    /// - **Numeric vs Numeric**: segment-by-segment with implicit zero padding
224    /// - **Semver vs Numeric** (either direction): extracts `[major, minor, patch]`
225    ///   from the semver side and compares as numeric segments
226    /// - **Deb vs Deb**: epoch (numeric), then upstream, then revision, via the
227    ///   Debian `dpkg` version-comparison algorithm
228    /// - **Rpm vs Rpm**: epoch (numeric), then version, then release, via rpm's
229    ///   `rpmvercmp` algorithm
230    /// - **Maven vs Maven**: item by item, via Maven's version-order algorithm.
231    ///   a version nesting past the parser's depth cap is declined
232    /// - **Pep440 against Pep440, Semver or Numeric** (either direction): the
233    ///   other side is read as a PEP 440 version and both are ordered per PEP
234    ///   440. a semver pre-release that isn't a PEP 440 suffix (say
235    ///   `1.0.0-foo.bar`) has no PEP 440 reading, so that pair stays `None`
236    /// - **Any other pair** (including any Opaque, any two of Deb, Rpm and
237    ///   Maven, or any of them against a semver/numeric/PEP 440 version):
238    ///   `None`
239    ///
240    /// deliberately weaker than [`PartialOrd`]: even two identical
241    /// [`Opaque`](Version::Opaque) versions compare `None`.
242    ///
243    /// which arm applies depends on how each side was parsed:
244    /// [`parse_for_ecosystem`](Self::parse_for_ecosystem) puts both sides of a
245    /// known ecosystem in the same variant, where
246    /// [`parse_lenient`](Self::parse_lenient) can infer different ones.
247    ///
248    /// # Examples
249    ///
250    /// ```
251    /// use std::cmp::Ordering;
252    /// use sbom_model::versions::Version;
253    ///
254    /// let a = Version::parse_lenient("2.0.0");
255    /// let b = Version::parse_lenient("1.5.0");
256    /// assert_eq!(a.partial_cmp_lenient(&b), Some(Ordering::Greater));
257    ///
258    /// let opaque = Version::parse_lenient("deadbeef");
259    /// assert_eq!(a.partial_cmp_lenient(&opaque), None);
260    /// ```
261    pub fn partial_cmp_lenient(&self, other: &Self) -> Option<Ordering> {
262        match (self, other) {
263            (Version::Semver(a), Version::Semver(b)) => Some(a.cmp_precedence(b)),
264            (Version::Numeric(a), Version::Numeric(b)) => Some(numeric_cmp(a, b)),
265            (Version::Semver(a), Version::Numeric(b)) => {
266                Some(numeric_cmp(&[a.major, a.minor, a.patch], b))
267            }
268            (Version::Numeric(a), Version::Semver(b)) => {
269                Some(numeric_cmp(a, &[b.major, b.minor, b.patch]))
270            }
271            (
272                Version::Deb {
273                    epoch: ae,
274                    upstream: au,
275                    revision: arev,
276                },
277                Version::Deb {
278                    epoch: be,
279                    upstream: bu,
280                    revision: brev,
281                },
282            ) => Some(deb_cmp((*ae, au, arev), (*be, bu, brev))),
283            (
284                Version::Rpm {
285                    epoch: ae,
286                    version: av,
287                    release: arel,
288                },
289                Version::Rpm {
290                    epoch: be,
291                    version: bv,
292                    release: brel,
293                },
294            ) => Some(rpm_cmp((*ae, av, arel), (*be, bv, brel))),
295            (Version::Maven(a), Version::Maven(b)) => maven_cmp(a, b),
296            (Version::Pep440(_), _) | (_, Version::Pep440(_)) => {
297                Some(pep440_cmp(&as_pep440(self)?, &as_pep440(other)?))
298            }
299            _ => None,
300        }
301    }
302
303    /// returns `true` if `new` is a downgrade from `self`.
304    ///
305    /// a pair whose ordering is unknown is not a downgrade; see
306    /// [`partial_cmp_lenient`](Self::partial_cmp_lenient) for the per-variant
307    /// comparison rules.
308    ///
309    /// # Examples
310    ///
311    /// ```
312    /// use sbom_model::versions::Version;
313    ///
314    /// let old = Version::parse_lenient("2.0.0");
315    /// let new = Version::parse_lenient("1.5.0");
316    /// assert!(old.is_downgrade(&new));
317    ///
318    /// let old = Version::parse_lenient("1.0.0");
319    /// let new = Version::parse_lenient("2.0.0");
320    /// assert!(!old.is_downgrade(&new));
321    /// ```
322    pub fn is_downgrade(&self, new: &Self) -> bool {
323        self.partial_cmp_lenient(new) == Some(Ordering::Greater)
324    }
325}
326
327/// the ruleset [`Version::parse_for_ecosystem`] reads a string with.
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329enum Scheme {
330    Infer,
331    Deb,
332    Rpm,
333    Maven,
334}
335
336impl Scheme {
337    fn for_ecosystem(ecosystem: Option<&str>) -> Self {
338        match ecosystem {
339            Some(e) if e.eq_ignore_ascii_case("deb") => Scheme::Deb,
340            Some(e) if e.eq_ignore_ascii_case("rpm") => Scheme::Rpm,
341            Some(e) if e.eq_ignore_ascii_case("maven") => Scheme::Maven,
342            _ => Scheme::Infer,
343        }
344    }
345}
346
347/// segment-by-segment numeric comparison with implicit zero padding.
348fn numeric_cmp(a: &[u64], b: &[u64]) -> Ordering {
349    let max_len = a.len().max(b.len());
350    for i in 0..max_len {
351        let x = a.get(i).copied().unwrap_or(0);
352        let y = b.get(i).copied().unwrap_or(0);
353        if x != y {
354            return x.cmp(&y);
355        }
356    }
357    Ordering::Equal
358}
359
360/// parses dot-separated numeric segments (e.g. four-part or leading-zero
361/// versions). returns `None` when any segment is non-numeric or the string is
362/// empty, so the caller can fall through to the next parsing strategy.
363fn parse_numeric(stripped: &str) -> Option<Vec<u64>> {
364    let mut segments = Vec::new();
365    for part in stripped.split('.') {
366        segments.push(part.parse::<u64>().ok()?);
367    }
368    if segments.is_empty() {
369        None
370    } else {
371        Some(segments)
372    }
373}
374
375/// pre-release spellings PEP 440 normalizes, longest first so `alpha` is not
376/// read as `a` with a trailing `lpha`.
377const PRE_ALIASES: [(&str, PreRelease); 8] = [
378    ("alpha", PreRelease::Alpha),
379    ("beta", PreRelease::Beta),
380    ("preview", PreRelease::Rc),
381    ("pre", PreRelease::Rc),
382    ("rc", PreRelease::Rc),
383    ("a", PreRelease::Alpha),
384    ("b", PreRelease::Beta),
385    ("c", PreRelease::Rc),
386];
387
388/// post-release spellings PEP 440 normalizes, longest first.
389const POST_ALIASES: [(&str, ()); 3] = [("post", ()), ("rev", ()), ("r", ())];
390
391const DEV_ALIASES: [(&str, ()); 1] = [("dev", ())];
392
393/// parses a PEP 440 version, returning `None` for anything not confidently PEP
394/// 440 so Debian and opaque strings fall through to the next strategy. the
395/// implicit post-release form (`1.0-1`) and the bare single-letter suffix
396/// (`1.0.2a`, `2024h`, `1.1.1a-r0`) are rejected: both are indistinguishable
397/// from a Debian upstream version.
398fn parse_pep440(s: &str) -> Option<Pep440> {
399    let lower = s.to_ascii_lowercase();
400
401    let (head, local) = match lower.split_once('+') {
402        Some((head, tail)) => (head, parse_local(tail)?),
403        None => (lower.as_str(), Vec::new()),
404    };
405    let (epoch, mut rest) = match head.split_once('!') {
406        Some((epoch, tail)) => (epoch.parse::<u64>().ok()?, tail),
407        None => (0, head),
408    };
409
410    let mut release = Vec::new();
411    loop {
412        let end = rest
413            .find(|c: char| !c.is_ascii_digit())
414            .unwrap_or(rest.len());
415        if end == 0 {
416            return None;
417        }
418        release.push(rest[..end].parse::<u64>().ok()?);
419        rest = &rest[end..];
420        match rest.strip_prefix('.') {
421            // a dot continues the release only when a digit follows it
422            Some(next) if next.starts_with(|c: char| c.is_ascii_digit()) => rest = next,
423            _ => break,
424        }
425    }
426
427    let (pre, rest) = match take_segment(rest, &PRE_ALIASES) {
428        Some((kind, n, rest)) => (Some((kind, n)), rest),
429        None => (None, rest),
430    };
431    let (post, rest) = match take_segment(rest, &POST_ALIASES) {
432        Some((_, n, rest)) => (Some(n), rest),
433        None => (None, rest),
434    };
435    let (dev, rest) = match take_segment(rest, &DEV_ALIASES) {
436        Some((_, n, rest)) => (Some(n), rest),
437        None => (None, rest),
438    };
439    if !rest.is_empty() {
440        return None;
441    }
442
443    Some(Pep440 {
444        epoch,
445        release,
446        pre,
447        post,
448        dev,
449        local,
450    })
451}
452
453/// consumes a `[-_.]?<keyword>[-_.]?<number>?` suffix, returning the matched
454/// keyword's tag, its number (an absent one is `0`, per PEP 440) and the rest.
455/// a single-letter keyword must be followed by at least one digit.
456fn take_segment<'a, T: Copy>(s: &'a str, aliases: &[(&str, T)]) -> Option<(T, u64, &'a str)> {
457    let body = s.strip_prefix(['-', '_', '.']).unwrap_or(s);
458    let (name, tag, rest) = aliases
459        .iter()
460        .find_map(|(name, tag)| Some((*name, *tag, body.strip_prefix(*name)?)))?;
461    let digits = rest.strip_prefix(['-', '_', '.']).unwrap_or(rest);
462    let end = digits
463        .find(|c: char| !c.is_ascii_digit())
464        .unwrap_or(digits.len());
465    let n = match end {
466        0 if name.len() == 1 => return None,
467        0 => 0,
468        _ => digits[..end].parse::<u64>().ok()?,
469    };
470    Some((tag, n, &digits[end..]))
471}
472
473/// parses a PEP 440 local version label (the part after `+`).
474fn parse_local(s: &str) -> Option<Vec<LocalSegment>> {
475    let mut segments = Vec::new();
476    for part in s.split(['-', '_', '.']) {
477        if part.is_empty() || !part.chars().all(|c| c.is_ascii_alphanumeric()) {
478            return None;
479        }
480        segments.push(match part.parse::<u64>() {
481            Ok(n) => LocalSegment::Num(n),
482            Err(_) => LocalSegment::Str(part.to_string()),
483        });
484    }
485    Some(segments)
486}
487
488/// reads a version as a PEP 440 version, so a `Pep440` can be compared against
489/// the semver and numeric spellings of the same release. returns `None` when
490/// there is no PEP 440 reading.
491fn as_pep440(v: &Version) -> Option<Pep440> {
492    let plain = |release| Pep440 {
493        epoch: 0,
494        release,
495        pre: None,
496        post: None,
497        dev: None,
498        local: Vec::new(),
499    };
500    match v {
501        Version::Pep440(p) => Some(p.clone()),
502        Version::Numeric(segments) => Some(plain(segments.clone())),
503        Version::Semver(s) if s.pre.is_empty() => Some(plain(vec![s.major, s.minor, s.patch])),
504        Version::Semver(s) => {
505            parse_pep440(&format!("{}.{}.{}-{}", s.major, s.minor, s.patch, s.pre))
506        }
507        Version::Deb { .. } | Version::Rpm { .. } | Version::Maven(_) | Version::Opaque(_) => None,
508    }
509}
510
511/// orders two PEP 440 versions: epoch, release (zero-padded), then the
512/// pre/post/dev segments, then the local label.
513fn pep440_cmp(a: &Pep440, b: &Pep440) -> Ordering {
514    a.epoch
515        .cmp(&b.epoch)
516        .then_with(|| numeric_cmp(&a.release, &b.release))
517        .then_with(|| pre_key(a).cmp(&pre_key(b)))
518        .then_with(|| a.post.cmp(&b.post))
519        .then_with(|| dev_key(a).cmp(&dev_key(b)))
520        .then_with(|| a.local.cmp(&b.local))
521}
522
523/// the pre-release sort key: a bare dev release precedes every pre-release of
524/// the same version, and a release with no pre-release segment follows them.
525#[derive(PartialEq, Eq, PartialOrd, Ord)]
526enum PreKey {
527    BeforeAll,
528    Pre(PreRelease, u64),
529    AfterAll,
530}
531
532fn pre_key(v: &Pep440) -> PreKey {
533    match v.pre {
534        Some((kind, n)) => PreKey::Pre(kind, n),
535        None if v.post.is_none() && v.dev.is_some() => PreKey::BeforeAll,
536        None => PreKey::AfterAll,
537    }
538}
539
540/// an absent dev segment sorts *after* any dev release, the reverse of `Option`.
541fn dev_key(v: &Pep440) -> (bool, u64) {
542    (v.dev.is_none(), v.dev.unwrap_or(0))
543}
544
545/// strips a leading `v`/`V` version prefix.
546fn strip_v_prefix(s: &str) -> &str {
547    s.strip_prefix('v')
548        .or_else(|| s.strip_prefix('V'))
549        .unwrap_or(s)
550}
551
552/// parses a Debian-style `epoch:upstream-revision` version.
553///
554/// returns `None` for strings that don't look like a comparable package
555/// version — the upstream part must start with a digit (the Debian convention)
556/// and every character must be in the Debian version alphabet — so that
557/// codenames, git hashes, and other genuinely opaque strings stay
558/// [`Opaque`](Version::Opaque) rather than being force-ordered.
559fn parse_deb(s: &str) -> Option<Version> {
560    let (epoch, rest) = split_epoch(s, &[':', '!']);
561
562    if !rest.starts_with(|c: char| c.is_ascii_digit()) {
563        return None;
564    }
565    if !rest.chars().all(is_deb_char) {
566        return None;
567    }
568
569    // the revision is everything after the last hyphen (dpkg splits there);
570    // an absent revision compares equal to "0".
571    let (upstream, revision) = match rest.rfind('-') {
572        Some(idx) => (rest[..idx].to_string(), rest[idx + 1..].to_string()),
573        None => (rest.to_string(), String::new()),
574    };
575
576    Some(Version::Deb {
577        epoch,
578        upstream,
579        revision,
580    })
581}
582
583/// splits a leading numeric epoch, delimited by any of `seps` (`:` for Debian
584/// and RPM, `!` for PEP 440), off a version string. returns `(0, s)` when there
585/// is no numeric epoch prefix.
586fn split_epoch<'a>(s: &'a str, seps: &[char]) -> (u64, &'a str) {
587    if let Some(idx) = s.find(seps) {
588        let (head, tail) = s.split_at(idx);
589        if !head.is_empty() && head.bytes().all(|b| b.is_ascii_digit()) {
590            if let Ok(epoch) = head.parse::<u64>() {
591                return (epoch, &tail[1..]);
592            }
593        }
594    }
595    (0, s)
596}
597
598/// characters permitted in a Debian upstream version or revision.
599fn is_deb_char(c: char) -> bool {
600    c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~' | ':')
601}
602
603/// orders two Debian-style versions given as `(epoch, upstream, revision)`:
604/// a higher epoch always wins; ties fall through to the upstream version and
605/// then the revision, both compared with [`verrevcmp`].
606fn deb_cmp(a: (u64, &str, &str), b: (u64, &str, &str)) -> Ordering {
607    a.0.cmp(&b.0)
608        .then_with(|| verrevcmp(a.1, b.1))
609        .then_with(|| verrevcmp(a.2, b.2))
610}
611
612/// the Debian `dpkg` version-component comparison (`verrevcmp`).
613///
614/// the two strings are scanned in lockstep, alternating between runs of
615/// non-digits and runs of digits. non-digit runs are compared lexically in the
616/// modified ordering of [`deb_order`]; digit runs are compared
617/// numerically (leading zeros stripped, longer run wins). this is the standard
618/// algorithm used for Debian upstream versions and revisions; RPM versions are
619/// ordered by [`rpmvercmp`] instead, which disagrees with it.
620fn verrevcmp(a: &str, b: &str) -> Ordering {
621    let a = a.as_bytes();
622    let b = b.as_bytes();
623    let mut i = 0;
624    let mut j = 0;
625
626    while i < a.len() || j < b.len() {
627        while (i < a.len() && !a[i].is_ascii_digit()) || (j < b.len() && !b[j].is_ascii_digit()) {
628            let ac = a.get(i).map_or(0, |&c| deb_order(c));
629            let bc = b.get(j).map_or(0, |&c| deb_order(c));
630            if ac != bc {
631                return ac.cmp(&bc);
632            }
633            i += 1;
634            j += 1;
635        }
636
637        while i < a.len() && a[i] == b'0' {
638            i += 1;
639        }
640        while j < b.len() && b[j] == b'0' {
641            j += 1;
642        }
643
644        let mut first_diff = 0i32;
645        while i < a.len() && a[i].is_ascii_digit() && j < b.len() && b[j].is_ascii_digit() {
646            if first_diff == 0 {
647                first_diff = i32::from(a[i]) - i32::from(b[j]);
648            }
649            i += 1;
650            j += 1;
651        }
652        // a longer remaining digit run means a larger number (no leading zeros
653        // remain), which takes precedence over any earlier per-digit difference.
654        if i < a.len() && a[i].is_ascii_digit() {
655            return Ordering::Greater;
656        }
657        if j < b.len() && b[j].is_ascii_digit() {
658            return Ordering::Less;
659        }
660        if first_diff != 0 {
661            return first_diff.cmp(&0);
662        }
663    }
664
665    Ordering::Equal
666}
667
668/// the per-character sort key used by [`verrevcmp`] for non-digit runs: a tilde
669/// sorts before everything (even the end of a string), letters keep their ASCII
670/// order, and all other characters sort after letters. digits and the end of a
671/// string both sort as `0`, so a digit encountered mid-scan behaves like a
672/// boundary (matching dpkg's `order()`).
673fn deb_order(c: u8) -> i32 {
674    if c.is_ascii_digit() {
675        0
676    } else if c.is_ascii_alphabetic() {
677        i32::from(c)
678    } else if c == b'~' {
679        -1
680    } else {
681        i32::from(c) + 256
682    }
683}
684
685/// parses an RPM `epoch:version-release` version.
686///
687/// returns `None` on the same grounds as [`parse_deb`]: the version must start
688/// with a digit and every character must be in the RPM version alphabet, so
689/// codenames, git hashes and other genuinely opaque strings stay
690/// [`Opaque`](Version::Opaque) rather than being force-ordered.
691fn parse_rpm(s: &str) -> Option<Version> {
692    let (epoch, rest) = split_epoch(s, &[':']);
693
694    if !rest.starts_with(|c: char| c.is_ascii_digit()) {
695        return None;
696    }
697    if !rest.chars().all(is_rpm_char) {
698        return None;
699    }
700
701    // rpm's `parseEVR` splits the release at the last hyphen, as dpkg does
702    let (version, release) = match rest.rfind('-') {
703        Some(idx) => (rest[..idx].to_string(), rest[idx + 1..].to_string()),
704        None => (rest.to_string(), String::new()),
705    };
706
707    Some(Version::Rpm {
708        epoch,
709        version,
710        release,
711    })
712}
713
714/// characters permitted in an RPM version or release. wider than
715/// [`is_deb_char`]: `_` is an ordinary separator in RPM versions and `^` marks a
716/// post-release snapshot.
717fn is_rpm_char(c: char) -> bool {
718    c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '~' | ':' | '_' | '^')
719}
720
721/// orders two RPM versions given as `(epoch, version, release)`: a higher epoch
722/// always wins; ties fall through to the version and then the release, both
723/// compared with [`rpmvercmp`].
724fn rpm_cmp(a: (u64, &str, &str), b: (u64, &str, &str)) -> Ordering {
725    a.0.cmp(&b.0)
726        .then_with(|| rpmvercmp(a.1, b.1))
727        .then_with(|| rpmvercmp(a.2, b.2))
728}
729
730/// rpm's own version-component comparison (`rpmvercmp`).
731///
732/// the two strings are scanned in lockstep, skipping separators on each side
733/// independently, so `1.0` and `1_0` are equal. `~` sorts before everything,
734/// including the end of a string; `^` sorts after the end of a string but
735/// before any longer continuation, so `1.0 < 1.0^ < 1.0.1`. otherwise each side
736/// yields its leading run of digits or of letters: a digit run outranks a letter
737/// run, two digit runs compare with leading zeros stripped and the longer run
738/// winning, and two letter runs compare bytewise. running out of string first
739/// loses.
740fn rpmvercmp(a: &str, b: &str) -> Ordering {
741    let a = a.as_bytes();
742    let b = b.as_bytes();
743    let mut i = 0;
744    let mut j = 0;
745
746    while i < a.len() || j < b.len() {
747        while i < a.len() && is_rpm_separator(a[i]) {
748            i += 1;
749        }
750        while j < b.len() && is_rpm_separator(b[j]) {
751            j += 1;
752        }
753
754        if a.get(i) == Some(&b'~') || b.get(j) == Some(&b'~') {
755            if a.get(i) != Some(&b'~') {
756                return Ordering::Greater;
757            }
758            if b.get(j) != Some(&b'~') {
759                return Ordering::Less;
760            }
761            i += 1;
762            j += 1;
763            continue;
764        }
765
766        if a.get(i) == Some(&b'^') || b.get(j) == Some(&b'^') {
767            if i == a.len() {
768                return Ordering::Less;
769            }
770            if j == b.len() {
771                return Ordering::Greater;
772            }
773            if a.get(i) != Some(&b'^') {
774                return Ordering::Greater;
775            }
776            if b.get(j) != Some(&b'^') {
777                return Ordering::Less;
778            }
779            i += 1;
780            j += 1;
781            continue;
782        }
783
784        if i == a.len() || j == b.len() {
785            break;
786        }
787
788        let numeric = a[i].is_ascii_digit();
789        let a_end = run_end(a, i, numeric);
790        let b_end = run_end(b, j, numeric);
791        // an empty run on the other side means different kinds; the digit wins
792        if b_end == j {
793            return if numeric {
794                Ordering::Greater
795            } else {
796                Ordering::Less
797            };
798        }
799
800        let mut x = &a[i..a_end];
801        let mut y = &b[j..b_end];
802        if numeric {
803            x = strip_leading_zeros(x);
804            y = strip_leading_zeros(y);
805            if x.len() != y.len() {
806                return x.len().cmp(&y.len());
807            }
808        }
809        match x.cmp(y) {
810            Ordering::Equal => {}
811            ord => return ord,
812        }
813
814        i = a_end;
815        j = b_end;
816    }
817
818    match (i == a.len(), j == b.len()) {
819        (true, true) => Ordering::Equal,
820        (true, false) => Ordering::Less,
821        _ => Ordering::Greater,
822    }
823}
824
825/// bytes [`rpmvercmp`] skips: anything that is not alphanumeric, `~` or `^`.
826fn is_rpm_separator(c: u8) -> bool {
827    !(c.is_ascii_alphanumeric() || matches!(c, b'~' | b'^'))
828}
829
830/// the end of the run of digits (or, when `numeric` is false, of letters)
831/// starting at `from`.
832fn run_end(s: &[u8], from: usize, numeric: bool) -> usize {
833    let in_run = |c: &&u8| {
834        if numeric {
835            c.is_ascii_digit()
836        } else {
837            c.is_ascii_alphabetic()
838        }
839    };
840    from + s[from..].iter().take_while(in_run).count()
841}
842
843/// drops a digit run's leading zeros, leaving an all-zero run empty.
844fn strip_leading_zeros(s: &[u8]) -> &[u8] {
845    let zeros = s.iter().take_while(|&&c| c == b'0').count();
846    &s[zeros..]
847}
848
849/// parses a Maven version.
850///
851/// returns `None` on the same grounds as [`parse_deb`]: the version must start
852/// with a digit and every character must be in the Maven version alphabet, so
853/// codenames like `RELEASE`, git hashes and other genuinely opaque strings stay
854/// [`Opaque`](Version::Opaque) rather than being force-ordered. a version whose
855/// item tree nests past [`MAVEN_MAX_DEPTH`] is declined the same way.
856fn parse_maven(s: &str) -> Option<Version> {
857    if !s.starts_with(|c: char| c.is_ascii_digit()) {
858        return None;
859    }
860    if !s.chars().all(is_maven_char) {
861        return None;
862    }
863    maven_parse(s)?;
864
865    Some(Version::Maven(s.to_string()))
866}
867
868/// characters permitted in a Maven version. wider than [`is_deb_char`]: `_` is
869/// a Maven separator, and non-ASCII letters and digits are ordinary qualifier
870/// characters.
871fn is_maven_char(c: char) -> bool {
872    c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | '+')
873}
874
875/// one node of a parsed Maven version: a run of ASCII digits with its leading
876/// zeros stripped, a qualifier folded to the spelling the ranking is defined
877/// on, or the sub-list a separator opens. each is null when empty.
878#[derive(Debug, Clone, PartialEq, Eq)]
879enum MavenItem {
880    Num(String),
881    Qual(String),
882    List(Vec<MavenItem>),
883}
884
885impl MavenItem {
886    fn is_null(&self) -> bool {
887        match self {
888            MavenItem::Num(digits) => digits.is_empty(),
889            MavenItem::Qual(value) => value.is_empty(),
890            MavenItem::List(items) => items.is_empty(),
891        }
892    }
893}
894
895/// the deepest item tree [`maven_parse`] will build. every level costs a frame
896/// in [`maven_list_cmp`], and real Maven versions nest a handful.
897const MAVEN_MAX_DEPTH: usize = 64;
898
899/// orders two Maven versions with Maven's version-order algorithm, or `None`
900/// when either nests past [`MAVEN_MAX_DEPTH`].
901fn maven_cmp(a: &str, b: &str) -> Option<Ordering> {
902    Some(maven_list_cmp(&maven_parse(a)?, &maven_parse(b)?))
903}
904
905/// parses a Maven version into its normalized item tree, or `None` when it
906/// nests past [`MAVEN_MAX_DEPTH`].
907///
908/// items are separated by `.`, `-`, `_` and by any transition between ASCII
909/// digits and other characters; every separator but `.` opens a sub-list, as
910/// does a qualifier reached from a digit or introduced by a `.` after an
911/// item. an empty item is Maven's `0`, so `1-.1` is `1-0.1`.
912fn maven_parse(s: &str) -> Option<Vec<MavenItem>> {
913    let s = s.to_lowercase();
914    let mut stack: Vec<Vec<MavenItem>> = vec![Vec::new()];
915    let mut digits = false;
916    let mut start = 0;
917
918    for (i, c) in s.char_indices() {
919        if matches!(c, '.' | '-' | '_') {
920            let item = if i == start {
921                MavenItem::Num(String::new())
922            } else {
923                maven_item(digits, &s[start..i])
924            };
925            stack.last_mut().expect("stack is never emptied").push(item);
926            start = i + c.len_utf8();
927            if c != '.' {
928                maven_open(&mut stack)?;
929            }
930            continue;
931        }
932
933        let is_digit = c.is_ascii_digit();
934        if i > start && is_digit && !digits {
935            if !stack.last().expect("stack is never emptied").is_empty() {
936                maven_open(&mut stack)?;
937            }
938            let qualifier = MavenItem::Qual(maven_qualifier(&s[start..i], true));
939            stack
940                .last_mut()
941                .expect("stack is never emptied")
942                .push(qualifier);
943            start = i;
944            maven_open(&mut stack)?;
945        } else if i > start && !is_digit && digits {
946            let number = maven_item(true, &s[start..i]);
947            stack
948                .last_mut()
949                .expect("stack is never emptied")
950                .push(number);
951            start = i;
952            maven_open(&mut stack)?;
953        }
954        digits = is_digit;
955    }
956
957    if s.len() > start {
958        if !digits && !stack.last().expect("stack is never emptied").is_empty() {
959            maven_open(&mut stack)?;
960        }
961        let item = maven_item(digits, &s[start..]);
962        stack.last_mut().expect("stack is never emptied").push(item);
963    }
964
965    while stack.len() > 1 {
966        let mut child = stack.pop().expect("length is above one");
967        maven_normalize(&mut child);
968        stack
969            .last_mut()
970            .expect("stack is never emptied")
971            .push(MavenItem::List(child));
972    }
973
974    let mut items = stack.pop().expect("stack is never emptied");
975    maven_normalize(&mut items);
976    Some(items)
977}
978
979/// opens a sub-list, or `None` at [`MAVEN_MAX_DEPTH`].
980fn maven_open(stack: &mut Vec<Vec<MavenItem>>) -> Option<()> {
981    if stack.len() >= MAVEN_MAX_DEPTH {
982        return None;
983    }
984    stack.push(Vec::new());
985    Some(())
986}
987
988/// classifies one item's text. `followed_by_digit` only matters for the
989/// `a`/`b`/`m` shorthands.
990fn maven_item(digits: bool, text: &str) -> MavenItem {
991    if digits {
992        MavenItem::Num(text.trim_start_matches('0').to_string())
993    } else {
994        MavenItem::Qual(maven_qualifier(text, false))
995    }
996}
997
998/// folds a qualifier to the spelling the ranking is defined on: lower case,
999/// `ga`/`final`/`release` to the empty release qualifier, `cr` to `rc`, and a
1000/// lone `a`/`b`/`m` directly followed by a digit to its long form.
1001fn maven_qualifier(text: &str, followed_by_digit: bool) -> String {
1002    let lower = text.to_lowercase();
1003
1004    if followed_by_digit {
1005        match lower.as_str() {
1006            "a" => return "alpha".to_string(),
1007            "b" => return "beta".to_string(),
1008            "m" => return "milestone".to_string(),
1009            _ => {}
1010        }
1011    }
1012
1013    match lower.as_str() {
1014        "ga" | "final" | "release" => String::new(),
1015        "cr" => "rc".to_string(),
1016        _ => lower,
1017    }
1018}
1019
1020/// drops a list's trailing null items, so that `1.0.0`, `1.ga` and `1-0` all
1021/// reduce to `1`. a sub-list is stepped over rather than ending the scan.
1022fn maven_normalize(items: &mut Vec<MavenItem>) {
1023    let mut i = items.len();
1024    while i > 0 {
1025        i -= 1;
1026        if items[i].is_null() {
1027            items.remove(i);
1028        } else if !matches!(items[i], MavenItem::List(_)) {
1029            break;
1030        }
1031    }
1032}
1033
1034/// orders two item lists, padding the shorter with the null each unmatched item
1035/// is measured against.
1036fn maven_list_cmp(a: &[MavenItem], b: &[MavenItem]) -> Ordering {
1037    for i in 0..a.len().max(b.len()) {
1038        let ord = match (a.get(i), b.get(i)) {
1039            (Some(x), Some(y)) => maven_item_cmp(x, y),
1040            (Some(x), None) => maven_null_cmp(x),
1041            (None, Some(y)) => maven_null_cmp(y).reverse(),
1042            (None, None) => Ordering::Equal,
1043        };
1044
1045        if ord != Ordering::Equal {
1046            return ord;
1047        }
1048    }
1049
1050    Ordering::Equal
1051}
1052
1053/// orders two items on the ranking `qualifier < sub-list < number`.
1054fn maven_item_cmp(a: &MavenItem, b: &MavenItem) -> Ordering {
1055    match (a, b) {
1056        (MavenItem::Num(x), MavenItem::Num(y)) => x.len().cmp(&y.len()).then_with(|| x.cmp(y)),
1057        (MavenItem::Qual(x), MavenItem::Qual(y)) => {
1058            maven_qualifier_rank(x).cmp(&maven_qualifier_rank(y))
1059        }
1060        (MavenItem::List(x), MavenItem::List(y)) => maven_list_cmp(x, y),
1061        (MavenItem::Num(_), _) => Ordering::Greater,
1062        (_, MavenItem::Num(_)) => Ordering::Less,
1063        (MavenItem::List(_), MavenItem::Qual(_)) => Ordering::Greater,
1064        (MavenItem::Qual(_), MavenItem::List(_)) => Ordering::Less,
1065    }
1066}
1067
1068/// orders an item against the absent one facing it: a number against `0`, a
1069/// qualifier against the release qualifier, a list against its own contents.
1070fn maven_null_cmp(item: &MavenItem) -> Ordering {
1071    match item {
1072        MavenItem::Num(digits) => {
1073            if digits.is_empty() {
1074                Ordering::Equal
1075            } else {
1076                Ordering::Greater
1077            }
1078        }
1079        MavenItem::Qual(value) => maven_qualifier_rank(value).cmp(&maven_qualifier_rank("")),
1080        MavenItem::List(items) => items
1081            .iter()
1082            .map(maven_null_cmp)
1083            .find(|ord| *ord != Ordering::Equal)
1084            .unwrap_or(Ordering::Equal),
1085    }
1086}
1087
1088/// the qualifier ranking: the named qualifiers in their documented order, then
1089/// every other one, lexically, above them all.
1090fn maven_qualifier_rank(q: &str) -> (usize, &str) {
1091    const KNOWN: [&str; 7] = ["alpha", "beta", "milestone", "rc", "snapshot", "", "sp"];
1092
1093    match KNOWN.iter().position(|known| *known == q) {
1094        Some(i) => (i, ""),
1095        None => (KNOWN.len(), q),
1096    }
1097}
1098
1099/// convenience function: returns `true` if `new_ver` is a downgrade from `old_ver`.
1100///
1101/// parses both strings with [`Version::parse_lenient`] and delegates to
1102/// [`Version::is_downgrade`].
1103pub fn is_version_downgrade(old_ver: &str, new_ver: &str) -> bool {
1104    Version::parse_lenient(old_ver).is_downgrade(&Version::parse_lenient(new_ver))
1105}
1106
1107/// convenience function: orders two version strings, returning `None` when the
1108/// ordering is unknown.
1109///
1110/// parses both strings with [`Version::parse_lenient`] and delegates to
1111/// [`Version::partial_cmp_lenient`].
1112pub fn compare_versions(a: &str, b: &str) -> Option<Ordering> {
1113    Version::parse_lenient(a).partial_cmp_lenient(&Version::parse_lenient(b))
1114}
1115
1116/// convenience function: returns `true` if `new_ver` is a downgrade from
1117/// `old_ver` under `ecosystem`'s version rules.
1118///
1119/// parses both strings with [`Version::parse_for_ecosystem`] and delegates to
1120/// [`Version::is_downgrade`]. a `None` ecosystem is exactly
1121/// [`is_version_downgrade`].
1122///
1123/// # Examples
1124///
1125/// ```
1126/// use sbom_model::versions::is_version_downgrade_for_ecosystem;
1127///
1128/// // a routine Ubuntu security update, not a downgrade
1129/// assert!(!is_version_downgrade_for_ecosystem(
1130///     Some("deb"),
1131///     "1.2.3-1ubuntu2",
1132///     "1.2.3-2"
1133/// ));
1134/// assert!(is_version_downgrade_for_ecosystem(Some("deb"), "1.2.3-2", "1.2.3-1ubuntu2"));
1135/// ```
1136pub fn is_version_downgrade_for_ecosystem(
1137    ecosystem: Option<&str>,
1138    old_ver: &str,
1139    new_ver: &str,
1140) -> bool {
1141    Version::parse_for_ecosystem(ecosystem, old_ver)
1142        .is_downgrade(&Version::parse_for_ecosystem(ecosystem, new_ver))
1143}
1144
1145/// convenience function: orders two version strings under `ecosystem`'s version
1146/// rules, returning `None` when the ordering is unknown.
1147///
1148/// parses both strings with [`Version::parse_for_ecosystem`] and delegates to
1149/// [`Version::partial_cmp_lenient`]. a `None` ecosystem is exactly
1150/// [`compare_versions`].
1151///
1152/// # Examples
1153///
1154/// ```
1155/// use std::cmp::Ordering;
1156/// use sbom_model::versions::compare_versions_for_ecosystem;
1157///
1158/// assert_eq!(
1159///     compare_versions_for_ecosystem(Some("deb"), "1.0~rc1", "1.0"),
1160///     Some(Ordering::Less)
1161/// );
1162///
1163/// // a `^` post-release snapshot, which the Debian alphabet has no reading for
1164/// assert_eq!(
1165///     compare_versions_for_ecosystem(Some("rpm"), "1.0^20200101git", "1.0"),
1166///     Some(Ordering::Greater)
1167/// );
1168/// ```
1169pub fn compare_versions_for_ecosystem(
1170    ecosystem: Option<&str>,
1171    a: &str,
1172    b: &str,
1173) -> Option<Ordering> {
1174    Version::parse_for_ecosystem(ecosystem, a)
1175        .partial_cmp_lenient(&Version::parse_for_ecosystem(ecosystem, b))
1176}
1177
1178#[cfg(test)]
1179mod tests {
1180    use super::*;
1181
1182    fn maven_cmp(a: &str, b: &str) -> Ordering {
1183        super::maven_cmp(a, b).unwrap_or_else(|| panic!("{a} vs {b} exceeds the depth cap"))
1184    }
1185
1186    #[test]
1187    fn parse_standard_semver() {
1188        let v = Version::parse_lenient("1.2.3");
1189        assert_eq!(v, Version::Semver(semver::Version::new(1, 2, 3)));
1190    }
1191
1192    #[test]
1193    fn parse_v_prefix() {
1194        assert_eq!(
1195            Version::parse_lenient("v1.2.3"),
1196            Version::Semver(semver::Version::new(1, 2, 3))
1197        );
1198        assert_eq!(
1199            Version::parse_lenient("V1.2.3"),
1200            Version::Semver(semver::Version::new(1, 2, 3))
1201        );
1202    }
1203
1204    #[test]
1205    fn parse_two_parts() {
1206        assert_eq!(
1207            Version::parse_lenient("1.2"),
1208            Version::Semver(semver::Version::new(1, 2, 0))
1209        );
1210    }
1211
1212    #[test]
1213    fn parse_single_part() {
1214        assert_eq!(
1215            Version::parse_lenient("42"),
1216            Version::Semver(semver::Version::new(42, 0, 0))
1217        );
1218    }
1219
1220    #[test]
1221    fn parse_prerelease() {
1222        let v = Version::parse_lenient("1.2.3-beta.1");
1223        match v {
1224            Version::Semver(sv) => {
1225                assert_eq!(sv.major, 1);
1226                assert_eq!(sv.minor, 2);
1227                assert_eq!(sv.patch, 3);
1228                assert!(!sv.pre.is_empty());
1229            }
1230            other => panic!("expected Semver, got {:?}", other),
1231        }
1232    }
1233
1234    #[test]
1235    fn parse_build_metadata() {
1236        let v = Version::parse_lenient("1.2.3+build.456");
1237        match v {
1238            Version::Semver(sv) => {
1239                assert_eq!((sv.major, sv.minor, sv.patch), (1, 2, 3));
1240                assert!(!sv.build.is_empty());
1241            }
1242            other => panic!("expected Semver, got {:?}", other),
1243        }
1244    }
1245
1246    #[test]
1247    fn parse_prerelease_and_build() {
1248        let v = Version::parse_lenient("1.0.0-alpha.1+build.789");
1249        match v {
1250            Version::Semver(sv) => {
1251                assert_eq!(sv.major, 1);
1252                assert!(!sv.pre.is_empty());
1253                assert!(!sv.build.is_empty());
1254            }
1255            other => panic!("expected Semver, got {:?}", other),
1256        }
1257    }
1258
1259    #[test]
1260    fn parse_v_prefix_two_parts() {
1261        assert_eq!(
1262            Version::parse_lenient("v1.2"),
1263            Version::Semver(semver::Version::new(1, 2, 0))
1264        );
1265    }
1266
1267    #[test]
1268    fn parse_v_prefix_single_part() {
1269        assert_eq!(
1270            Version::parse_lenient("v5"),
1271            Version::Semver(semver::Version::new(5, 0, 0))
1272        );
1273    }
1274
1275    #[test]
1276    fn parse_v_prefix_prerelease() {
1277        let v = Version::parse_lenient("v2.0.0-rc.1");
1278        match v {
1279            Version::Semver(sv) => {
1280                assert_eq!(sv.major, 2);
1281                assert!(!sv.pre.is_empty());
1282            }
1283            other => panic!("expected Semver, got {:?}", other),
1284        }
1285    }
1286
1287    #[test]
1288    fn parse_zero_version() {
1289        assert_eq!(
1290            Version::parse_lenient("0.0.0"),
1291            Version::Semver(semver::Version::new(0, 0, 0))
1292        );
1293    }
1294
1295    #[test]
1296    fn parse_large_numbers() {
1297        assert_eq!(
1298            Version::parse_lenient("999.888.777"),
1299            Version::Semver(semver::Version::new(999, 888, 777))
1300        );
1301    }
1302
1303    #[test]
1304    fn parse_single_zero() {
1305        assert_eq!(
1306            Version::parse_lenient("0"),
1307            Version::Semver(semver::Version::new(0, 0, 0))
1308        );
1309    }
1310
1311    #[test]
1312    fn parse_four_part_is_numeric() {
1313        assert_eq!(
1314            Version::parse_lenient("1.2.3.4"),
1315            Version::Numeric(vec![1, 2, 3, 4])
1316        );
1317    }
1318
1319    #[test]
1320    fn parse_date_based_is_numeric() {
1321        // leading zeros are rejected by semver but u64 parses them fine
1322        assert_eq!(
1323            Version::parse_lenient("2024.01.15"),
1324            Version::Numeric(vec![2024, 1, 15])
1325        );
1326    }
1327
1328    #[test]
1329    fn parse_v_prefix_four_part_is_numeric() {
1330        // the v-prefix must be stripped before the numeric fallback splits
1331        assert_eq!(
1332            Version::parse_lenient("v1.2.3.4"),
1333            Version::Numeric(vec![1, 2, 3, 4])
1334        );
1335        assert_eq!(
1336            Version::parse_lenient("V1.2.3.4"),
1337            Version::Numeric(vec![1, 2, 3, 4])
1338        );
1339    }
1340
1341    #[test]
1342    fn parse_v_prefix_date_based_is_numeric() {
1343        assert_eq!(
1344            Version::parse_lenient("v2024.01.15"),
1345            Version::Numeric(vec![2024, 1, 15])
1346        );
1347    }
1348
1349    #[test]
1350    fn parse_leading_zeros_is_numeric() {
1351        assert_eq!(
1352            Version::parse_lenient("01.02.03"),
1353            Version::Numeric(vec![1, 2, 3])
1354        );
1355    }
1356
1357    #[test]
1358    fn parse_non_numeric_is_opaque() {
1359        assert_eq!(Version::parse_lenient("abc"), Version::Opaque("abc".into()));
1360        assert_eq!(
1361            Version::parse_lenient("foo.bar.baz"),
1362            Version::Opaque("foo.bar.baz".into())
1363        );
1364    }
1365
1366    #[test]
1367    fn parse_whitespace_is_opaque() {
1368        assert!(matches!(
1369            Version::parse_lenient(" 1.2.3"),
1370            Version::Opaque(_)
1371        ));
1372        assert!(matches!(
1373            Version::parse_lenient("1.2.3 "),
1374            Version::Opaque(_)
1375        ));
1376    }
1377
1378    #[test]
1379    fn parse_empty_string_is_opaque() {
1380        assert!(matches!(Version::parse_lenient(""), Version::Opaque(_)));
1381    }
1382
1383    #[test]
1384    fn downgrade_semver() {
1385        assert!(is_version_downgrade("2.0.0", "1.5.0"));
1386        assert!(is_version_downgrade("1.1.0", "1.0.0"));
1387        assert!(is_version_downgrade("1.0.1", "1.0.0"));
1388    }
1389
1390    #[test]
1391    fn upgrade_semver_not_flagged() {
1392        assert!(!is_version_downgrade("1.0.0", "1.1.0"));
1393        assert!(!is_version_downgrade("1.0.0", "2.0.0"));
1394        assert!(!is_version_downgrade("1.0.0", "1.0.1"));
1395    }
1396
1397    #[test]
1398    fn equal_semver_not_flagged() {
1399        assert!(!is_version_downgrade("1.0.0", "1.0.0"));
1400    }
1401
1402    #[test]
1403    fn downgrade_v_prefix() {
1404        assert!(is_version_downgrade("v2.0.0", "v1.0.0"));
1405        assert!(!is_version_downgrade("v1.0.0", "v2.0.0"));
1406    }
1407
1408    #[test]
1409    fn downgrade_prerelease() {
1410        assert!(is_version_downgrade("1.0.0", "1.0.0-rc1"));
1411        assert!(!is_version_downgrade("1.0.0-rc1", "1.0.0"));
1412    }
1413
1414    #[test]
1415    fn downgrade_build_metadata() {
1416        // SemVer §10: build metadata MUST be ignored when determining
1417        // precedence, so a build-metadata-only change is never a downgrade in
1418        // either direction.
1419        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.2"));
1420        assert!(!is_version_downgrade("1.0.0+build.2", "1.0.0+build.1"));
1421        assert!(!is_version_downgrade("1.0.0+build.1", "1.0.0+build.1"));
1422        // commit-hash build metadata (common in generated SBOMs) must not trip
1423        // the gate regardless of lexical ordering of the hashes.
1424        assert!(!is_version_downgrade("1.0.0+c144a98", "1.0.0+bc17664"));
1425        assert!(!is_version_downgrade("1.0.0+build.10", "1.0.0+build.9"));
1426    }
1427
1428    #[test]
1429    fn downgrade_mixed_v_prefix() {
1430        assert!(is_version_downgrade("v2.0.0", "1.0.0"));
1431        assert!(is_version_downgrade("2.0.0", "v1.0.0"));
1432        assert!(!is_version_downgrade("v1.0.0", "2.0.0"));
1433        assert!(!is_version_downgrade("1.0.0", "v2.0.0"));
1434    }
1435
1436    #[test]
1437    fn downgrade_prerelease_ordering() {
1438        assert!(is_version_downgrade("1.0.0-beta.1", "1.0.0-alpha.1"));
1439        assert!(is_version_downgrade("1.0.0-rc.1", "1.0.0-beta.1"));
1440        assert!(!is_version_downgrade("1.0.0-alpha.1", "1.0.0-beta.1"));
1441        assert!(!is_version_downgrade("1.0.0-beta.1", "1.0.0-rc.1"));
1442    }
1443
1444    #[test]
1445    fn downgrade_prerelease_numeric_ordering() {
1446        assert!(is_version_downgrade("1.0.0-rc.2", "1.0.0-rc.1"));
1447        assert!(!is_version_downgrade("1.0.0-rc.1", "1.0.0-rc.2"));
1448    }
1449
1450    #[test]
1451    fn downgrade_equal_with_v_prefix() {
1452        assert!(!is_version_downgrade("v1.0.0", "v1.0.0"));
1453    }
1454
1455    #[test]
1456    fn downgrade_padded_two_part() {
1457        assert!(is_version_downgrade("1.2", "1.1"));
1458        assert!(!is_version_downgrade("1.1", "1.2"));
1459        assert!(!is_version_downgrade("1.2", "1.2"));
1460    }
1461
1462    #[test]
1463    fn downgrade_padded_single_part() {
1464        assert!(is_version_downgrade("2", "1"));
1465        assert!(!is_version_downgrade("1", "2"));
1466        assert!(!is_version_downgrade("5", "5"));
1467    }
1468
1469    #[test]
1470    fn downgrade_mixed_part_counts_semver() {
1471        assert!(is_version_downgrade("2.0", "1.9.9"));
1472        assert!(!is_version_downgrade("1.9.9", "2.0"));
1473    }
1474
1475    #[test]
1476    fn downgrade_v_prefix_two_part() {
1477        assert!(is_version_downgrade("v2.0", "v1.0"));
1478        assert!(!is_version_downgrade("v1.0", "v2.0"));
1479    }
1480
1481    #[test]
1482    fn downgrade_four_part() {
1483        assert!(is_version_downgrade("1.2.3.4", "1.2.3.3"));
1484        assert!(!is_version_downgrade("1.2.3.3", "1.2.3.4"));
1485        assert!(!is_version_downgrade("1.2.3.4", "1.2.3.4"));
1486    }
1487
1488    #[test]
1489    fn downgrade_date_based() {
1490        assert!(is_version_downgrade("2024.01.15", "2023.12.01"));
1491        assert!(!is_version_downgrade("2023.12.01", "2024.01.15"));
1492    }
1493
1494    #[test]
1495    fn downgrade_v_prefix_four_part() {
1496        // v-prefixed four-part versions parse to Numeric, so the downgrade
1497        // gate sees them instead of silently treating them as Opaque
1498        assert!(is_version_downgrade("v1.2.3.4", "v1.2.3.3"));
1499        assert!(!is_version_downgrade("v1.2.3.3", "v1.2.3.4"));
1500        assert!(!is_version_downgrade("v1.2.3.4", "v1.2.3.4"));
1501    }
1502
1503    #[test]
1504    fn downgrade_v_prefix_date_based() {
1505        assert!(is_version_downgrade("v2024.01.15", "v2023.12.01"));
1506        assert!(!is_version_downgrade("v2023.12.01", "v2024.01.15"));
1507    }
1508
1509    #[test]
1510    fn downgrade_non_numeric_not_flagged() {
1511        assert!(!is_version_downgrade("abc", "def"));
1512        assert!(!is_version_downgrade("foo.bar", "foo.baz"));
1513    }
1514
1515    #[test]
1516    fn downgrade_numeric_unequal_length() {
1517        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
1518        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
1519    }
1520
1521    #[test]
1522    fn downgrade_large_major_numeric_equal() {
1523        // "2024.1.15" has no leading zeros, so it parses as valid semver
1524        assert!(!is_version_downgrade("2024.1.15", "2024.1.15"));
1525    }
1526
1527    #[test]
1528    fn downgrade_semver_vs_four_part() {
1529        // "1.2.3" → Semver, "1.2.3.4" → Numeric; cross-comparison extracts
1530        // [major,minor,patch] from the semver side
1531        assert!(!is_version_downgrade("1.2.3", "1.2.3.4"));
1532        assert!(is_version_downgrade("1.2.3.4", "1.2.3"));
1533    }
1534
1535    #[test]
1536    fn downgrade_v_prefix_vs_four_part() {
1537        // cross-variant comparison works after stripping the v-prefix during parse.
1538        assert!(!is_version_downgrade("v1.2.3", "1.2.3.4"));
1539        assert!(is_version_downgrade("1.2.3.4", "v1.2.3"));
1540    }
1541
1542    #[test]
1543    fn downgrade_empty_strings() {
1544        assert!(!is_version_downgrade("", "1.0.0"));
1545        assert!(!is_version_downgrade("1.0.0", ""));
1546        assert!(!is_version_downgrade("", ""));
1547    }
1548
1549    // --- Debian/RPM epoch/upstream/revision parsing ---
1550
1551    #[test]
1552    fn parse_epoch_is_deb() {
1553        // versions with an epoch aren't semver
1554        assert!(matches!(
1555            Version::parse_lenient("2:1.0"),
1556            Version::Deb { .. }
1557        ));
1558        assert!(matches!(
1559            Version::parse_lenient("1:9.0"),
1560            Version::Deb { .. }
1561        ));
1562    }
1563
1564    #[test]
1565    fn parse_revision_is_deb() {
1566        // "5.1-3" is not valid semver (two-part base)
1567        assert!(matches!(
1568            Version::parse_lenient("5.1-3"),
1569            Version::Deb { .. }
1570        ));
1571    }
1572
1573    #[test]
1574    fn parse_deb_fields() {
1575        match Version::parse_lenient("2:1.2.3-4") {
1576            Version::Deb {
1577                epoch,
1578                upstream,
1579                revision,
1580            } => {
1581                assert_eq!(epoch, 2);
1582                assert_eq!(upstream, "1.2.3");
1583                assert_eq!(revision, "4");
1584            }
1585            other => panic!("expected Deb, got {:?}", other),
1586        }
1587    }
1588
1589    #[test]
1590    fn parse_deb_revision_splits_at_last_hyphen() {
1591        // "1.2.3-2-1" is valid semver (pre-release "2-1"), so use a two-part
1592        // base that semver rejects to exercise the last-hyphen revision split
1593        match Version::parse_lenient("1.2-2-1") {
1594            Version::Deb {
1595                epoch,
1596                upstream,
1597                revision,
1598            } => {
1599                assert_eq!(epoch, 0);
1600                assert_eq!(upstream, "1.2-2");
1601                assert_eq!(revision, "1");
1602            }
1603            other => panic!("expected Deb, got {:?}", other),
1604        }
1605    }
1606
1607    #[test]
1608    fn parse_pep440_epoch_is_pep440() {
1609        match Version::parse_lenient("1!2.0") {
1610            Version::Pep440(p) => {
1611                assert_eq!(p.epoch, 1);
1612                assert_eq!(p.release, vec![2, 0]);
1613                assert_eq!(p.pre, None);
1614            }
1615            other => panic!("expected Pep440, got {:?}", other),
1616        }
1617    }
1618
1619    #[test]
1620    fn parse_deb_keeps_epoch_bang_forms_it_declines() {
1621        // a Debian revision after a PEP 440 epoch is not a PEP 440 version
1622        match Version::parse_lenient("1!2.0-3") {
1623            Version::Deb {
1624                epoch,
1625                upstream,
1626                revision,
1627            } => {
1628                assert_eq!(epoch, 1);
1629                assert_eq!(upstream, "2.0");
1630                assert_eq!(revision, "3");
1631            }
1632            other => panic!("expected Deb, got {:?}", other),
1633        }
1634    }
1635
1636    #[test]
1637    fn parse_tilde_prerelease_is_deb() {
1638        // tilde pre-release strings aren't semver but are comparable Debian versions
1639        assert!(matches!(
1640            Version::parse_lenient("1.0.0~rc1"),
1641            Version::Deb { .. }
1642        ));
1643    }
1644
1645    #[test]
1646    fn parse_codename_stays_opaque() {
1647        // a leading non-digit means it isn't a comparable package version
1648        assert!(matches!(
1649            Version::parse_lenient("focal-1"),
1650            Version::Opaque(_)
1651        ));
1652        assert!(matches!(
1653            Version::parse_lenient("stable"),
1654            Version::Opaque(_)
1655        ));
1656        // a bare numeric epoch with a non-version tail is not comparable either
1657        assert!(matches!(
1658            Version::parse_lenient("1:stable"),
1659            Version::Opaque(_)
1660        ));
1661    }
1662
1663    // --- Debian/RPM downgrade detection ---
1664
1665    #[test]
1666    fn downgrade_epoch() {
1667        // a higher epoch always wins, regardless of the upstream version
1668        assert!(is_version_downgrade("2:1.0", "1:9.0"));
1669        assert!(!is_version_downgrade("1:9.0", "2:1.0"));
1670        // epoch dominates: epoch up beats a lower upstream, epoch down beats a higher one
1671        assert!(!is_version_downgrade("1:1.0", "2:0.1"));
1672        assert!(is_version_downgrade("2:0.1", "1:1.0"));
1673    }
1674
1675    #[test]
1676    fn downgrade_epoch_equal_upstream() {
1677        assert!(is_version_downgrade("1:2.0", "1:1.0"));
1678        assert!(!is_version_downgrade("1:1.0", "1:2.0"));
1679        assert!(!is_version_downgrade("1:1.0", "1:1.0"));
1680    }
1681
1682    #[test]
1683    fn downgrade_implicit_epoch_zero() {
1684        // an absent epoch is 0, so adding an epoch is an upgrade, dropping to
1685        // an explicit 0 is neutral
1686        assert!(!is_version_downgrade("5.1-1", "1:0.1-1"));
1687        assert!(is_version_downgrade("1:0.1-1", "0:0.1-1"));
1688    }
1689
1690    #[test]
1691    fn downgrade_revision() {
1692        assert!(is_version_downgrade("5.1-3", "5.1-2"));
1693        assert!(!is_version_downgrade("5.1-2", "5.1-3"));
1694        assert!(!is_version_downgrade("5.1-2", "5.1-2"));
1695    }
1696
1697    #[test]
1698    fn downgrade_upstream_trumps_revision() {
1699        // equal revision, upstream down -> downgrade
1700        assert!(is_version_downgrade("1:5.2-1", "1:5.1-1"));
1701        // upstream up, revision down -> upgrade (upstream is compared first)
1702        assert!(!is_version_downgrade("1:5.1-9", "1:5.2-1"));
1703    }
1704
1705    #[test]
1706    fn downgrade_absent_revision_equals_zero() {
1707        // an absent revision compares as "0"; "1.0" is semver so pin the epoch
1708        // to force Debian parsing on both sides
1709        assert!(is_version_downgrade("1:2.0-1", "1:2.0"));
1710        assert!(!is_version_downgrade("1:2.0", "1:2.0-1"));
1711    }
1712
1713    #[test]
1714    fn downgrade_rpm_release_with_epoch() {
1715        // an epoch forces Debian parsing even though the tail resembles a
1716        // semver pre-release; RPM `.elN` release tails order numerically
1717        assert!(is_version_downgrade("1:1.2.3-2.el8", "1:1.2.3-1.el8"));
1718        assert!(!is_version_downgrade("1:1.2.3-1.el8", "1:1.2.3-2.el8"));
1719        // el8 is newer than el7
1720        assert!(is_version_downgrade("1:1.2.3-1.el8", "1:1.2.3-1.el7"));
1721        assert!(!is_version_downgrade("1:1.2.3-1.el7", "1:1.2.3-1.el8"));
1722    }
1723
1724    #[test]
1725    fn downgrade_deb_numeric_not_lexical() {
1726        // 10 > 9 numerically even though "9" > "1" lexically
1727        assert!(is_version_downgrade("1.10-1", "1.9-1"));
1728        assert!(!is_version_downgrade("1.9-1", "1.10-1"));
1729    }
1730
1731    #[test]
1732    fn downgrade_deb_tilde_prerelease() {
1733        // a tilde sorts before everything, so ~rc2 > ~rc1 and ~rc1 < the release
1734        assert!(is_version_downgrade("1.0.0~rc2", "1.0.0~rc1"));
1735        assert!(!is_version_downgrade("1.0.0~rc1", "1.0.0~rc2"));
1736        assert!(is_version_downgrade("1:1.0~rc1", "1:1.0~beta1"));
1737    }
1738
1739    #[test]
1740    fn downgrade_real_world_deb() {
1741        // openssl with epoch and an Ubuntu security revision
1742        assert!(is_version_downgrade(
1743            "1:1.1.1f-1ubuntu2.16",
1744            "1:1.1.1f-1ubuntu2.15"
1745        ));
1746        assert!(!is_version_downgrade(
1747            "1:1.1.1f-1ubuntu2.15",
1748            "1:1.1.1f-1ubuntu2.16"
1749        ));
1750    }
1751
1752    #[test]
1753    fn downgrade_deb_opaque_not_flagged() {
1754        // codenames and other non-version strings remain uncomparable
1755        assert!(!is_version_downgrade("focal", "bionic"));
1756        assert!(!is_version_downgrade("1:stable", "1:oldstable"));
1757    }
1758
1759    #[test]
1760    fn downgrade_deb_vs_semver_not_flagged() {
1761        // cross-format comparison stays conservative (returns false)
1762        assert!(!is_version_downgrade("2:1.0", "1.0.0"));
1763        assert!(!is_version_downgrade("1.0.0", "2:1.0"));
1764    }
1765
1766    #[test]
1767    fn deb_canonical_ordering_vectors() {
1768        use Ordering::{Equal, Greater, Less};
1769
1770        // canonical dpkg (`verrevcmp`) orderings for the edge cases the other
1771        // tests don't fully pin, each `expected` derived by hand from the
1772        // `deb_order`/`verrevcmp` rules documented above. every string pins an
1773        // epoch so it forces `Deb` parsing — a bare `1.0`/`1.0~rc1` would parse
1774        // as Semver/Numeric and exercise the wrong comparator (see
1775        // `downgrade_absent_revision_equals_zero`). `expected` is how `a` orders
1776        // relative to `b`; the harness drives each vector through the public
1777        // `is_version_downgrade` in both directions.
1778        let cases = [
1779            // tilde chain: `~` < end-of-string < letters < other punctuation,
1780            // so 1.0~~ < 1.0~~a < 1.0~ < 1.0 < 1.0a
1781            ("1:1.0~~", "1:1.0~~a", Less),
1782            ("1:1.0~~a", "1:1.0~", Less),
1783            ("1:1.0~", "1:1.0", Less),
1784            ("1:1.0", "1:1.0a", Less),
1785            // tilde marks a pre-release: it sorts before the release, and
1786            // pre-releases order among themselves
1787            ("1:1.0~rc1", "1:1.0", Less),
1788            ("1:1.0~rc1", "1:1.0~rc2", Less),
1789            // digit runs compare numerically, not lexically: 10 > 9
1790            ("1:1.10", "1:1.9", Greater),
1791            // leading zeros don't change a digit run's value
1792            ("1:1.0", "1:1.00", Equal),
1793            ("1:1.01", "1:1.1", Equal),
1794            // a letter outranks a digit at a component boundary...
1795            ("1:1.a", "1:1.1", Greater),
1796            // ...but a continuing digit run still outranks a letter suffix
1797            ("1:1.0a", "1:1.01", Less),
1798            // epoch dominates the upstream comparison
1799            ("2:0.1", "1:9.9", Greater),
1800            // upstream is compared before the revision
1801            ("1:5.2-1", "1:5.1-9", Greater),
1802            // an absent revision compares equal to an explicit "0"
1803            ("1:2.0", "1:2.0-0", Equal),
1804            // revision digit runs are numeric too: 10 > 9
1805            ("1:2.0-10", "1:2.0-9", Greater),
1806        ];
1807
1808        for (a, b, expected) in cases {
1809            // guard the vector: if either side stops parsing as Deb, it would
1810            // silently test a different comparator and prove nothing.
1811            assert!(
1812                matches!(Version::parse_lenient(a), Version::Deb { .. }),
1813                "{a} no longer parses as Deb"
1814            );
1815            assert!(
1816                matches!(Version::parse_lenient(b), Version::Deb { .. }),
1817                "{b} no longer parses as Deb"
1818            );
1819            match expected {
1820                // a < b: going b -> a is a downgrade, a -> b is not
1821                Less => {
1822                    assert!(is_version_downgrade(b, a), "expected {a} < {b}");
1823                    assert!(!is_version_downgrade(a, b), "expected {a} < {b}");
1824                }
1825                // a > b: going a -> b is a downgrade, b -> a is not
1826                Greater => {
1827                    assert!(is_version_downgrade(a, b), "expected {a} > {b}");
1828                    assert!(!is_version_downgrade(b, a), "expected {a} > {b}");
1829                }
1830                // a == b: neither direction is a downgrade
1831                Equal => {
1832                    assert!(!is_version_downgrade(a, b), "expected {a} == {b}");
1833                    assert!(!is_version_downgrade(b, a), "expected {a} == {b}");
1834                }
1835            }
1836        }
1837    }
1838
1839    #[test]
1840    fn compare_orders_comparable_variant_pairs() {
1841        use Ordering::{Equal, Greater, Less};
1842
1843        for (a, b, expected) in [
1844            ("2.0.0", "1.5.0", Greater),
1845            ("1.0.0", "1.0.0", Equal),
1846            ("1.2.3.4", "1.2.3.3", Greater),
1847            ("1.2.3", "1.2.3.4", Less),
1848            ("2:1.0-3", "1:9.0-1", Greater),
1849            ("5.1-3", "5.1-3", Equal),
1850        ] {
1851            assert_eq!(compare_versions(a, b), Some(expected), "{a} vs {b}");
1852            assert_eq!(
1853                compare_versions(b, a),
1854                Some(expected.reverse()),
1855                "{b} vs {a}"
1856            );
1857        }
1858    }
1859
1860    #[test]
1861    fn compare_leaves_opaque_and_mixed_variants_unordered() {
1862        for (a, b) in [
1863            ("deadbeef", "1.0.0"),
1864            ("deadbeef", "cafebabe"),
1865            ("deadbeef", "deadbeef"),
1866            ("2:1.0-3", "1.0.0"),
1867            ("5.1-3", "5.1.0.0"),
1868        ] {
1869            assert_eq!(compare_versions(a, b), None, "{a} vs {b}");
1870            assert_eq!(compare_versions(b, a), None, "{b} vs {a}");
1871        }
1872    }
1873
1874    // --- PEP 440 (Python) parsing and ordering ---
1875
1876    #[test]
1877    fn parse_pep440_suffixes() {
1878        for s in [
1879            "1.0rc1",
1880            "1.0a1",
1881            "1.0b1",
1882            "1.0.dev1",
1883            "1.0.post1",
1884            "4.2.0rc1",
1885            "1!1.0",
1886            "1.0alpha1",
1887            "1.0-rc-1",
1888            "1.0_beta_2",
1889            "1.0.RC1",
1890            "2.0.post2.dev3",
1891            "1.0rc1+ubuntu.1",
1892        ] {
1893            assert!(
1894                matches!(Version::parse_lenient(s), Version::Pep440(_)),
1895                "{s} should parse as Pep440"
1896            );
1897        }
1898    }
1899
1900    #[test]
1901    fn parse_pep440_fields() {
1902        match Version::parse_lenient("2!4.2.0.post3.dev7") {
1903            Version::Pep440(p) => {
1904                assert_eq!(p.epoch, 2);
1905                assert_eq!(p.release, vec![4, 2, 0]);
1906                assert_eq!(p.pre, None);
1907                assert_eq!(p.post, Some(3));
1908                assert_eq!(p.dev, Some(7));
1909                assert!(p.local.is_empty());
1910            }
1911            other => panic!("expected Pep440, got {:?}", other),
1912        }
1913    }
1914
1915    #[test]
1916    fn parse_pep440_leaves_other_formats_alone() {
1917        for s in [
1918            "1.2.3",
1919            "v1.2",
1920            "42",
1921            "1.2.3-beta.1",
1922            "1.0.0-alpha.1+build.789",
1923            "0.0.0",
1924        ] {
1925            assert!(
1926                matches!(Version::parse_lenient(s), Version::Semver(_)),
1927                "{s} should still be Semver"
1928            );
1929        }
1930        for s in ["1.2.3.4", "2024.01.15", "01.02.03", "v1.2.3.4"] {
1931            assert!(
1932                matches!(Version::parse_lenient(s), Version::Numeric(_)),
1933                "{s} should still be Numeric"
1934            );
1935        }
1936        for s in [
1937            "2:1.0",
1938            "5.1-3",
1939            "2:1.2.3-4",
1940            "1.2-2-1",
1941            "1.0.0~rc1",
1942            "1:1.1.1f-1ubuntu2.16",
1943            "1.0+ubuntu.1",
1944        ] {
1945            assert!(
1946                matches!(Version::parse_lenient(s), Version::Deb { .. }),
1947                "{s} should still be Deb"
1948            );
1949        }
1950        for s in ["abc", "foo.bar.baz", "focal-1", "stable", "1:stable", ""] {
1951            assert!(
1952                matches!(Version::parse_lenient(s), Version::Opaque(_)),
1953                "{s} should still be Opaque"
1954            );
1955        }
1956    }
1957
1958    /// asserts the strings are in strictly ascending order, every pair.
1959    fn assert_ascending(versions: &[&str]) {
1960        for (i, a) in versions.iter().enumerate() {
1961            for b in &versions[i + 1..] {
1962                assert_eq!(
1963                    compare_versions(a, b),
1964                    Some(Ordering::Less),
1965                    "expected {a} < {b}"
1966                );
1967                assert_eq!(
1968                    compare_versions(b, a),
1969                    Some(Ordering::Greater),
1970                    "expected {b} > {a}"
1971                );
1972                assert!(is_version_downgrade(b, a), "expected {b} -> {a} downgrade");
1973                assert!(!is_version_downgrade(a, b), "expected {a} -> {b} upgrade");
1974            }
1975        }
1976    }
1977
1978    #[test]
1979    fn pep440_release_cycle_ordering() {
1980        assert_ascending(&[
1981            "1.0.dev1",
1982            "1.0a1",
1983            "1.0a2",
1984            "1.0b1",
1985            "1.0rc1",
1986            "1.0",
1987            "1.0.post1",
1988            "1.0.1",
1989        ]);
1990    }
1991
1992    #[test]
1993    fn pep440_dev_ordering_within_segments() {
1994        assert_ascending(&["1.0.dev1", "1.0a1.dev1", "1.0a1", "1.0"]);
1995        assert_ascending(&["1.0", "1.0.post1.dev1", "1.0.post1"]);
1996    }
1997
1998    #[test]
1999    fn pep440_epoch_ordering() {
2000        assert_ascending(&["2.0", "1!1.0", "1!2.0", "2!0.1"]);
2001    }
2002
2003    #[test]
2004    fn pep440_spelling_aliases() {
2005        for (canonical, aliases) in [
2006            ("1.0a1", ["1.0alpha1", "1.0.ALPHA.1", "1.0-a-1"]),
2007            ("1.0b1", ["1.0beta1", "1.0.BETA.1", "1.0_b_1"]),
2008            ("1.0rc1", ["1.0c1", "1.0pre1", "1.0preview1"]),
2009            ("1.0.post1", ["1.0rev1", "1.0r1", "1.0-POST-1"]),
2010        ] {
2011            for alias in aliases {
2012                assert_eq!(
2013                    compare_versions(canonical, alias),
2014                    Some(Ordering::Equal),
2015                    "{alias} should normalize to {canonical}"
2016                );
2017            }
2018        }
2019        // an omitted number is an implicit 0, so 1.0rc < 1.0rc1
2020        assert_eq!(compare_versions("1.0rc", "1.0rc0"), Some(Ordering::Equal));
2021        assert_eq!(compare_versions("1.0rc", "1.0rc1"), Some(Ordering::Less));
2022    }
2023
2024    #[test]
2025    fn pep440_compares_against_semver_and_numeric() {
2026        use Ordering::{Equal, Greater, Less};
2027
2028        for (a, b, expected) in [
2029            // the silently-skipped transitions: one side parses Semver
2030            ("4.2.0rc1", "4.2.0", Less),
2031            ("1.0rc1", "1.0", Less),
2032            ("1.0.dev1", "1.0", Less),
2033            ("1.0", "1.0.post1", Less),
2034            ("1!1.0", "2.0", Greater),
2035            ("1.0.post1", "1.0.1", Less),
2036            // implicit zero padding across the two spellings of one release
2037            ("1.0.post0", "1.0.0.post0", Equal),
2038            // ...and against a four-part version, which parses Numeric
2039            ("1.2.3.4rc1", "1.2.3.4", Less),
2040            ("1.2.3.4.dev1", "1.2.3.3", Greater),
2041            // a semver pre-release that is also a PEP 440 pre-release
2042            ("1.0.0-rc1", "1.0rc2", Less),
2043            ("1.0.0-alpha.1", "1.0b1", Less),
2044        ] {
2045            assert_eq!(compare_versions(a, b), Some(expected), "{a} vs {b}");
2046            assert_eq!(
2047                compare_versions(b, a),
2048                Some(expected.reverse()),
2049                "{b} vs {a}"
2050            );
2051        }
2052    }
2053
2054    #[test]
2055    fn pep440_local_version_ordering() {
2056        // a local label outranks the same version without one
2057        assert_ascending(&["1.0rc1", "1.0rc1+ubuntu", "1.0rc1+ubuntu.1"]);
2058        assert_ascending(&["1.0rc1+abc", "1.0rc1+1"]);
2059        assert_ascending(&["1.0rc1+build.9", "1.0rc1+build.10"]);
2060        assert_eq!(
2061            compare_versions("1.0rc1+UBUNTU-1", "1.0rc1+ubuntu.1"),
2062            Some(Ordering::Equal)
2063        );
2064    }
2065
2066    #[test]
2067    fn pep440_stays_uncomparable_against_deb_and_opaque() {
2068        for (a, b) in [
2069            ("1.0rc1", "2:1.0"),
2070            ("1.0rc1", "1.0.0~rc1"),
2071            ("1.0rc1", "deadbeef"),
2072            // a semver pre-release with no PEP 440 reading
2073            ("1.0.0-foo.bar", "1.0rc1"),
2074        ] {
2075            assert_eq!(compare_versions(a, b), None, "{a} vs {b}");
2076            assert_eq!(compare_versions(b, a), None, "{b} vs {a}");
2077        }
2078    }
2079
2080    #[test]
2081    fn downgrade_pep440_gate() {
2082        // the false positive: a normal Python pre-release progression
2083        assert!(!is_version_downgrade("1.0.dev1", "1.0a1"));
2084        assert!(is_version_downgrade("1.0a1", "1.0.dev1"));
2085        // the silent skips
2086        assert!(!is_version_downgrade("4.2.0rc1", "4.2.0"));
2087        assert!(is_version_downgrade("4.2.0", "4.2.0rc1"));
2088        assert!(!is_version_downgrade("1.0", "1.0.post1"));
2089        assert!(is_version_downgrade("1.0.post1", "1.0"));
2090        assert!(!is_version_downgrade("1.0rc1", "1.0rc1"));
2091    }
2092
2093    // --- letter-suffixed OS package versions (OpenSSL, tzdata, Alpine) ---
2094
2095    /// `pattern` with its `@` replaced by each letter of `a`..`z` in turn.
2096    fn letter_suffixed(pattern: &str) -> Vec<String> {
2097        ('a'..='z')
2098            .map(|c| pattern.replace('@', &c.to_string()))
2099            .collect()
2100    }
2101
2102    #[test]
2103    fn bare_single_letter_suffix_parses_as_deb() {
2104        for pattern in ["1.0.2@", "2024@", "1.1.1@-r0", "1.0@"] {
2105            for s in letter_suffixed(pattern) {
2106                assert!(
2107                    matches!(Version::parse_lenient(&s), Version::Deb { .. }),
2108                    "{s} should parse as Deb"
2109                );
2110            }
2111        }
2112    }
2113
2114    #[test]
2115    fn letter_suffixed_versions_order_across_the_alphabet() {
2116        for pattern in ["1.0.2@", "2024@", "1.1.1@-r0"] {
2117            let versions = letter_suffixed(pattern);
2118            let refs: Vec<&str> = versions.iter().map(String::as_str).collect();
2119            assert_ascending(&refs);
2120        }
2121        assert_ascending(&["2024a", "2024h", "2025a", "2025b"]);
2122        assert_ascending(&["1.1.1a-r0", "1.1.1d-r0", "1.1.1d-r1", "1.1.1w-r0"]);
2123    }
2124
2125    #[test]
2126    fn bare_keyword_longer_than_one_letter_stays_pep440() {
2127        for s in [
2128            "1.0rc",
2129            "1.0.dev",
2130            "1.0.post",
2131            "1.0alpha",
2132            "1.0beta",
2133            "1.0pre",
2134            "1.0preview",
2135            "1.0rev",
2136            "2.0.post2.dev3",
2137        ] {
2138            assert!(
2139                matches!(Version::parse_lenient(s), Version::Pep440(_)),
2140                "{s} should still parse as Pep440"
2141            );
2142        }
2143    }
2144
2145    #[test]
2146    fn single_letter_alias_with_a_number_stays_pep440() {
2147        for s in [
2148            "1.0a1",
2149            "1.0b1",
2150            "1.0c1",
2151            "1.0r1",
2152            "1.0a0",
2153            "1.0-a-1",
2154            "1.0_b_2",
2155            "1.0.c.3",
2156            "1!2.0a1",
2157            "1.0a1+ubuntu.1",
2158        ] {
2159            assert!(
2160                matches!(Version::parse_lenient(s), Version::Pep440(_)),
2161                "{s} should still parse as Pep440"
2162            );
2163        }
2164    }
2165
2166    #[test]
2167    fn downgrade_letter_suffix_gate() {
2168        assert!(is_version_downgrade("1.0.2d", "1.0.2c"));
2169        assert!(!is_version_downgrade("1.0.2c", "1.0.2d"));
2170        assert!(is_version_downgrade("2025a", "2024h"));
2171        assert!(!is_version_downgrade("2024h", "2025a"));
2172        assert!(is_version_downgrade("1.1.1d-r0", "1.1.1a-r0"));
2173        assert!(!is_version_downgrade("1.1.1a-r0", "1.1.1d-r0"));
2174        assert!(!is_version_downgrade("1.0.2a", "1.0.2a"));
2175        // a base release and its letter releases are Semver against Deb
2176        assert_eq!(compare_versions("1.0.2", "1.0.2a"), None);
2177        assert!(!is_version_downgrade("1.0.2", "1.0.2a"));
2178        assert!(!is_version_downgrade("1.0.2a", "1.0.2"));
2179    }
2180
2181    #[test]
2182    fn downgrade_agrees_with_compare() {
2183        use Ordering::Greater;
2184
2185        for (a, b) in [
2186            ("2.0.0", "1.5.0"),
2187            ("1.0.0", "2.0.0"),
2188            ("1.0.0", "1.0.0"),
2189            ("2024.01.15", "2024.01.14"),
2190            ("2:1.0-3", "1:9.0-1"),
2191            ("1.0.0+build.10", "1.0.0+build.9"),
2192            ("deadbeef", "1.0.0"),
2193        ] {
2194            assert_eq!(
2195                is_version_downgrade(a, b),
2196                compare_versions(a, b) == Some(Greater),
2197                "{a} -> {b}"
2198            );
2199        }
2200    }
2201
2202    /// version strings spanning every shape the two entry points disagree about.
2203    const ECOSYSTEM_CORPUS: &[&str] = &[
2204        "1",
2205        "1.0",
2206        "1.0.0",
2207        "1.2.3",
2208        "v1.2.3",
2209        "2024.01.15",
2210        "1.2.3.4",
2211        "1.0.0-alpha.1",
2212        "1.0.0-alpha.2",
2213        "1.0.0-rc.1",
2214        "1.0.0+build.9",
2215        "1.0.0-foo.bar",
2216        "4.2.0rc1",
2217        "1.0.dev1",
2218        "1.0a1",
2219        "1!1.0",
2220        "1.0.post1",
2221        "1.0.2a",
2222        "1.2.3-1",
2223        "1.2.3-2",
2224        "1.2.3-1ubuntu2",
2225        "1.2.3-1build1",
2226        "1.2.3-1+deb11u1",
2227        "1.0~rc1",
2228        "2:1.0-3",
2229        "4.4.2-2.el7_9",
2230        "deadbeef",
2231        "",
2232    ];
2233
2234    #[test]
2235    fn unknown_ecosystem_parses_exactly_like_parse_lenient() {
2236        for eco in [None, Some("npm"), Some("cargo"), Some("golang")] {
2237            for s in ECOSYSTEM_CORPUS {
2238                assert_eq!(
2239                    Version::parse_for_ecosystem(eco, s),
2240                    Version::parse_lenient(s),
2241                    "{eco:?} / {s}"
2242                );
2243            }
2244        }
2245    }
2246
2247    #[test]
2248    fn unknown_ecosystem_orders_exactly_like_the_string_only_path() {
2249        for eco in [None, Some("npm"), Some("cargo"), Some("golang")] {
2250            for a in ECOSYSTEM_CORPUS {
2251                for b in ECOSYSTEM_CORPUS {
2252                    assert_eq!(
2253                        compare_versions_for_ecosystem(eco, a, b),
2254                        compare_versions(a, b),
2255                        "{eco:?} / {a} vs {b}"
2256                    );
2257                    assert_eq!(
2258                        is_version_downgrade_for_ecosystem(eco, a, b),
2259                        is_version_downgrade(a, b),
2260                        "{eco:?} / {a} -> {b}"
2261                    );
2262                }
2263            }
2264        }
2265    }
2266
2267    #[test]
2268    fn semver_prereleases_keep_their_semver_reading() {
2269        for eco in [None, Some("npm"), Some("cargo")] {
2270            assert_eq!(
2271                compare_versions_for_ecosystem(eco, "1.0.0-alpha.1", "1.0.0"),
2272                Some(Ordering::Less),
2273                "{eco:?}"
2274            );
2275            assert!(!is_version_downgrade_for_ecosystem(
2276                eco,
2277                "1.0.0-alpha.1",
2278                "1.0.0"
2279            ));
2280            assert!(is_version_downgrade_for_ecosystem(
2281                eco,
2282                "1.0.0",
2283                "1.0.0-alpha.1"
2284            ));
2285        }
2286    }
2287
2288    #[test]
2289    fn deb_ecosystem_parses_as_deb() {
2290        assert_eq!(
2291            Version::parse_for_ecosystem(Some("deb"), "1.2.3-1ubuntu2"),
2292            Version::Deb {
2293                epoch: 0,
2294                upstream: "1.2.3".into(),
2295                revision: "1ubuntu2".into(),
2296            }
2297        );
2298        assert!(matches!(
2299            Version::parse_for_ecosystem(Some("deb"), "1.2.3"),
2300            Version::Deb { .. }
2301        ));
2302        assert!(matches!(
2303            Version::parse_for_ecosystem(Some("deb"), "1.0.0-alpha.1"),
2304            Version::Deb { .. }
2305        ));
2306    }
2307
2308    #[test]
2309    fn deb_ecosystem_match_ignores_case() {
2310        assert_eq!(
2311            Version::parse_for_ecosystem(Some("DEB"), "1.2.3-1ubuntu2"),
2312            Version::parse_for_ecosystem(Some("deb"), "1.2.3-1ubuntu2")
2313        );
2314    }
2315
2316    #[test]
2317    fn deb_ecosystem_does_not_retry_a_non_deb_string_as_semver() {
2318        assert_eq!(
2319            Version::parse_for_ecosystem(Some("deb"), "4.4.2-2.el7_9"),
2320            Version::Opaque("4.4.2-2.el7_9".into())
2321        );
2322        assert!(matches!(
2323            Version::parse_for_ecosystem(Some("deb"), "v1.2.3-1ubuntu2"),
2324            Version::Deb { .. }
2325        ));
2326    }
2327
2328    /// every expectation checked against `dpkg --compare-versions` on Debian.
2329    #[test]
2330    fn deb_ecosystem_strips_a_v_prefix_instead_of_skipping_the_pair() {
2331        use Ordering::{Greater, Less};
2332
2333        for (a, b, expected) in [
2334            ("v1.2.3", "v1.2.4", Less),
2335            ("v1.2.10", "v1.2.9", Greater),
2336            ("V1.2.3", "V1.2.4", Less),
2337            ("v1.2.3-1ubuntu2", "v1.2.3-2", Less),
2338        ] {
2339            assert_eq!(
2340                compare_versions_for_ecosystem(Some("deb"), a, b),
2341                Some(expected),
2342                "{a} vs {b}"
2343            );
2344            assert_eq!(
2345                compare_versions_for_ecosystem(Some("deb"), b, a),
2346                Some(expected.reverse()),
2347                "{b} vs {a}"
2348            );
2349        }
2350
2351        assert!(is_version_downgrade_for_ecosystem(
2352            Some("deb"),
2353            "v1.2.4",
2354            "v1.2.3"
2355        ));
2356    }
2357
2358    /// every expectation checked against `dpkg --compare-versions` on Debian.
2359    #[test]
2360    fn deb_ecosystem_orders_revisions_the_way_dpkg_does() {
2361        use Ordering::{Greater, Less};
2362
2363        for (a, b, expected) in [
2364            ("1.2.3-1ubuntu2", "1.2.3-2", Less),
2365            ("1.2.3-1build1", "1.2.3-2", Less),
2366            ("1.2.3-2ubuntu0.1", "1.2.3-3", Less),
2367            ("1.2.3-1+deb11u1", "1.2.3-2", Less),
2368            ("1.2.3-1+deb11u1", "1.2.3-1+deb11u2", Less),
2369            ("1.2.3-1ubuntu2", "1.2.3-1ubuntu1", Greater),
2370            ("1.2.3-1", "1.2.3-10", Less),
2371            ("1.2.3", "1.2.3-1", Less),
2372            ("1.0-1", "1.0", Greater),
2373            ("1.0~rc1", "1.0", Less),
2374            ("1.0~rc1-1", "1.0-1", Less),
2375            ("1.2.3-1~bpo11+1", "1.2.3-1", Less),
2376            ("2:1.0-1", "10.0-1", Greater),
2377            ("1.1.1n-0+deb11u5", "1.1.1o-1", Less),
2378        ] {
2379            assert_eq!(
2380                compare_versions_for_ecosystem(Some("deb"), a, b),
2381                Some(expected),
2382                "{a} vs {b}"
2383            );
2384            assert_eq!(
2385                compare_versions_for_ecosystem(Some("deb"), b, a),
2386                Some(expected.reverse()),
2387                "{b} vs {a}"
2388            );
2389        }
2390    }
2391
2392    #[test]
2393    fn deb_ecosystem_clears_the_false_downgrade_the_string_only_path_reports() {
2394        assert!(!is_version_downgrade_for_ecosystem(
2395            Some("deb"),
2396            "1.2.3-1ubuntu2",
2397            "1.2.3-2"
2398        ));
2399        assert!(is_version_downgrade("1.2.3-1ubuntu2", "1.2.3-2"));
2400        assert!(is_version_downgrade_for_ecosystem(
2401            Some("deb"),
2402            "1.2.3-2",
2403            "1.2.3-1ubuntu2"
2404        ));
2405    }
2406
2407    #[test]
2408    fn deb_ecosystem_catches_the_downgrades_the_string_only_path_passed() {
2409        for (old, new) in [
2410            ("1.2.3-2", "1.2.3-1ubuntu2"),
2411            ("1.2.3-3", "1.2.3-2ubuntu0.1"),
2412            ("1.2.3-1+deb11u2", "1.2.3-1+deb11u1"),
2413        ] {
2414            assert!(!is_version_downgrade(old, new), "{old} -> {new}");
2415            assert!(
2416                is_version_downgrade_for_ecosystem(Some("deb"), old, new),
2417                "{old} -> {new}"
2418            );
2419        }
2420    }
2421
2422    #[test]
2423    fn deb_ecosystem_orders_plus_revisions_the_string_only_path_read_as_equal() {
2424        assert_eq!(
2425            compare_versions("1.2.3-1+deb11u1", "1.2.3-1+deb11u2"),
2426            Some(Ordering::Equal)
2427        );
2428        assert_eq!(
2429            compare_versions_for_ecosystem(Some("deb"), "1.2.3-1+deb11u1", "1.2.3-1+deb11u2"),
2430            Some(Ordering::Less)
2431        );
2432    }
2433
2434    #[test]
2435    fn deb_ecosystem_makes_previously_uncomparable_pairs_comparable() {
2436        for (a, b) in [("1.0-1", "1.0"), ("1.0~rc1", "1.0"), ("1.0.2a", "1.0.2")] {
2437            assert_eq!(compare_versions(a, b), None, "{a} vs {b}");
2438            assert!(
2439                compare_versions_for_ecosystem(Some("deb"), a, b).is_some(),
2440                "{a} vs {b}"
2441            );
2442        }
2443    }
2444
2445    /// rpm's own `tests/rpmvercmp.at` assertion list, one case per row.
2446    /// `expected` is how `a` orders relative to `b`.
2447    #[test]
2448    fn rpmvercmp_upstream_vectors() {
2449        use Ordering::{Equal, Greater, Less};
2450
2451        for (a, b, expected) in [
2452            ("1.0", "1.0", Equal),
2453            ("1.0", "2.0", Less),
2454            ("2.0", "1.0", Greater),
2455            ("2.0.1", "2.0.1", Equal),
2456            ("2.0", "2.0.1", Less),
2457            ("2.0.1", "2.0", Greater),
2458            ("2.0.1a", "2.0.1a", Equal),
2459            ("2.0.1a", "2.0.1", Greater),
2460            ("2.0.1", "2.0.1a", Less),
2461            ("5.5p1", "5.5p1", Equal),
2462            ("5.5p1", "5.5p2", Less),
2463            ("5.5p2", "5.5p1", Greater),
2464            ("5.5p10", "5.5p10", Equal),
2465            ("5.5p1", "5.5p10", Less),
2466            ("5.5p10", "5.5p1", Greater),
2467            ("10xyz", "10.1xyz", Less),
2468            ("10.1xyz", "10xyz", Greater),
2469            ("xyz10", "xyz10", Equal),
2470            ("xyz10", "xyz10.1", Less),
2471            ("xyz10.1", "xyz10", Greater),
2472            ("xyz.4", "xyz.4", Equal),
2473            ("xyz.4", "8", Less),
2474            ("8", "xyz.4", Greater),
2475            ("xyz.4", "2", Less),
2476            ("2", "xyz.4", Greater),
2477            ("5.5p2", "5.6p1", Less),
2478            ("5.6p1", "5.5p2", Greater),
2479            ("5.6p1", "6.5p1", Less),
2480            ("6.5p1", "5.6p1", Greater),
2481            ("6.0.rc1", "6.0", Greater),
2482            ("6.0", "6.0.rc1", Less),
2483            ("10b2", "10a1", Greater),
2484            ("10a2", "10b2", Less),
2485            ("1.0aa", "1.0aa", Equal),
2486            ("1.0a", "1.0aa", Less),
2487            ("1.0aa", "1.0a", Greater),
2488            ("10.0001", "10.0001", Equal),
2489            ("10.0001", "10.1", Equal),
2490            ("10.1", "10.0001", Equal),
2491            ("10.0001", "10.0039", Less),
2492            ("10.0039", "10.0001", Greater),
2493            ("4.999.9", "5.0", Less),
2494            ("5.0", "4.999.9", Greater),
2495            ("20101121", "20101121", Equal),
2496            ("20101121", "20101122", Less),
2497            ("20101122", "20101121", Greater),
2498            ("2_0", "2_0", Equal),
2499            ("2.0", "2_0", Equal),
2500            ("2_0", "2.0", Equal),
2501            ("a", "a", Equal),
2502            ("a+", "a+", Equal),
2503            ("a+", "a_", Equal),
2504            ("a_", "a+", Equal),
2505            ("+a", "+a", Equal),
2506            ("+a", "_a", Equal),
2507            ("_a", "+a", Equal),
2508            ("+_", "+_", Equal),
2509            ("_+", "+_", Equal),
2510            ("_+", "_+", Equal),
2511            ("+", "_", Equal),
2512            ("_", "+", Equal),
2513            ("1.0~rc1", "1.0~rc1", Equal),
2514            ("1.0~rc1", "1.0", Less),
2515            ("1.0", "1.0~rc1", Greater),
2516            ("1.0~rc1", "1.0~rc2", Less),
2517            ("1.0~rc2", "1.0~rc1", Greater),
2518            ("1.0~rc1~git123", "1.0~rc1~git123", Equal),
2519            ("1.0~rc1~git123", "1.0~rc1", Less),
2520            ("1.0~rc1", "1.0~rc1~git123", Greater),
2521            ("1.0^", "1.0^", Equal),
2522            ("1.0^", "1.0", Greater),
2523            ("1.0", "1.0^", Less),
2524            ("1.0^git1", "1.0^git1", Equal),
2525            ("1.0^git1", "1.0", Greater),
2526            ("1.0", "1.0^git1", Less),
2527            ("1.0^git1", "1.0^git2", Less),
2528            ("1.0^git2", "1.0^git1", Greater),
2529            ("1.0^git1", "1.01", Less),
2530            ("1.01", "1.0^git1", Greater),
2531            ("1.0^20160101", "1.0^20160101", Equal),
2532            ("1.0^20160101", "1.0.1", Less),
2533            ("1.0.1", "1.0^20160101", Greater),
2534            ("1.0^20160101^git1", "1.0^20160101^git1", Equal),
2535            ("1.0^20160102", "1.0^20160101^git1", Greater),
2536            ("1.0^20160101^git1", "1.0^20160102", Less),
2537            ("1.0~rc1^git1", "1.0~rc1^git1", Equal),
2538            ("1.0~rc1^git1", "1.0~rc1", Greater),
2539            ("1.0~rc1", "1.0~rc1^git1", Less),
2540            ("1.0^git1~pre", "1.0^git1~pre", Equal),
2541            ("1.0^git1", "1.0^git1~pre", Greater),
2542            ("1.0^git1~pre", "1.0^git1", Less),
2543            // upstream keeps these as documented quirks: the alpha run is
2544            // compared against "fc", so 'b' loses and 'g' wins
2545            ("1b.fc17", "1b.fc17", Equal),
2546            ("1b.fc17", "1.fc17", Less),
2547            ("1.fc17", "1b.fc17", Greater),
2548            ("1g.fc17", "1g.fc17", Equal),
2549            ("1g.fc17", "1.fc17", Greater),
2550            ("1.fc17", "1g.fc17", Less),
2551            // non-ASCII bytes are separators, so these are all equal
2552            ("1.1.α", "1.1.α", Equal),
2553            ("1.1.α", "1.1.β", Equal),
2554            ("1.1.β", "1.1.α", Equal),
2555            ("1.1.αα", "1.1.α", Equal),
2556            ("1.1.α", "1.1.ββ", Equal),
2557            ("1.1.ββ", "1.1.αα", Equal),
2558        ] {
2559            assert_eq!(rpmvercmp(a, b), expected, "{a} vs {b}");
2560        }
2561    }
2562
2563    /// derived from the algorithm: upstream's vectors exercise `rpmvercmp`
2564    /// alone, never the epoch and release `parseEVR` splits off ahead of it.
2565    #[test]
2566    fn rpm_ecosystem_orders_epoch_then_version_then_release() {
2567        use Ordering::{Equal, Greater, Less};
2568
2569        for (a, b, expected) in [
2570            ("2:1.0-1", "1:9.9-9", Greater),
2571            ("1.0-1", "0:1.0-1", Equal),
2572            ("1.1-1", "1.0-9", Greater),
2573            ("1.0-2", "1.0-10", Less),
2574            ("1.0-1.el8", "1.0-1.el9", Less),
2575            ("1.0-0", "1.0-1", Less),
2576            // an absent release sorts below every release, including `0`
2577            ("1.0", "1.0-0", Less),
2578        ] {
2579            assert_eq!(
2580                compare_versions_for_ecosystem(Some("rpm"), a, b),
2581                Some(expected),
2582                "{a} vs {b}"
2583            );
2584            assert_eq!(
2585                compare_versions_for_ecosystem(Some("rpm"), b, a),
2586                Some(expected.reverse()),
2587                "{b} vs {a}"
2588            );
2589        }
2590    }
2591
2592    /// the pairs rpm and dpkg return different verdicts for. every `deb`
2593    /// expectation was checked against `dpkg --compare-versions`.
2594    #[test]
2595    fn rpm_and_deb_disagree_on_ordinary_versions() {
2596        use Ordering::{Equal, Greater, Less};
2597
2598        for (a, b, deb, rpm) in [
2599            // an alpha run against a digit run: dpkg ranks the letter above the
2600            // digit, rpm below it
2601            ("1.a", "1.1", Some(Greater), Some(Less)),
2602            ("1.fc35", "1.1", Some(Greater), Some(Less)),
2603            // `_` is a plain separator in rpm and outside the Debian alphabet
2604            ("1.0", "1_0", None, Some(Equal)),
2605            // `^` marks a post-release snapshot, which sorts above the base
2606            ("1.0^20200101gitabc", "1.0", None, Some(Greater)),
2607            // a stock RHEL release string, unreadable under the Debian alphabet
2608            ("4.4.2-2.el7_9", "4.4.2-3.el7_9", None, Some(Less)),
2609        ] {
2610            assert_eq!(
2611                compare_versions_for_ecosystem(Some("deb"), a, b),
2612                deb,
2613                "deb: {a} vs {b}"
2614            );
2615            assert_eq!(
2616                compare_versions_for_ecosystem(Some("rpm"), a, b),
2617                rpm,
2618                "rpm: {a} vs {b}"
2619            );
2620        }
2621    }
2622
2623    #[test]
2624    fn rpm_ecosystem_reverses_a_gate_the_deb_rules_fire_backwards() {
2625        assert!(is_version_downgrade_for_ecosystem(
2626            Some("deb"),
2627            "1.a",
2628            "1.1"
2629        ));
2630        assert!(!is_version_downgrade_for_ecosystem(
2631            Some("rpm"),
2632            "1.a",
2633            "1.1"
2634        ));
2635        assert!(is_version_downgrade_for_ecosystem(
2636            Some("rpm"),
2637            "1.1",
2638            "1.a"
2639        ));
2640    }
2641
2642    #[test]
2643    fn rpm_ecosystem_parses_as_rpm() {
2644        assert_eq!(
2645            Version::parse_for_ecosystem(Some("rpm"), "5.1.8-2.fc35"),
2646            Version::Rpm {
2647                epoch: 0,
2648                version: "5.1.8".into(),
2649                release: "2.fc35".into(),
2650            }
2651        );
2652        assert_eq!(
2653            Version::parse_for_ecosystem(Some("rpm"), "1:2.36.1-2.fc35"),
2654            Version::Rpm {
2655                epoch: 1,
2656                version: "2.36.1".into(),
2657                release: "2.fc35".into(),
2658            }
2659        );
2660        assert_eq!(
2661            Version::parse_for_ecosystem(Some("rpm"), "4.4.2-2.el7_9"),
2662            Version::Rpm {
2663                epoch: 0,
2664                version: "4.4.2".into(),
2665                release: "2.el7_9".into(),
2666            }
2667        );
2668        assert!(matches!(
2669            Version::parse_for_ecosystem(Some("rpm"), "1.2.3"),
2670            Version::Rpm { .. }
2671        ));
2672        assert!(matches!(
2673            Version::parse_for_ecosystem(Some("rpm"), "1.0.0-alpha.1"),
2674            Version::Rpm { .. }
2675        ));
2676    }
2677
2678    #[test]
2679    fn rpm_ecosystem_match_ignores_case() {
2680        assert_eq!(
2681            Version::parse_for_ecosystem(Some("RPM"), "5.1.8-2.fc35"),
2682            Version::parse_for_ecosystem(Some("rpm"), "5.1.8-2.fc35")
2683        );
2684    }
2685
2686    #[test]
2687    fn rpm_ecosystem_keeps_codenames_and_hashes_opaque() {
2688        for s in ["deadbeef", "focal", "", "stable", "1.0 "] {
2689            assert_eq!(
2690                Version::parse_for_ecosystem(Some("rpm"), s),
2691                Version::Opaque(s.to_string()),
2692                "{s}"
2693            );
2694        }
2695    }
2696
2697    #[test]
2698    fn rpm_ecosystem_strips_a_v_prefix_instead_of_skipping_the_pair() {
2699        assert!(matches!(
2700            Version::parse_for_ecosystem(Some("rpm"), "v1.2.3-1"),
2701            Version::Rpm { .. }
2702        ));
2703        assert_eq!(
2704            compare_versions_for_ecosystem(Some("rpm"), "v1.2.3", "v1.2.4"),
2705            Some(Ordering::Less)
2706        );
2707    }
2708
2709    #[test]
2710    fn rpm_stays_uncomparable_against_every_other_parse_result() {
2711        let rpm = Version::parse_for_ecosystem(Some("rpm"), "1.2.3-1");
2712        for other in [
2713            Version::parse_for_ecosystem(Some("deb"), "1.2.3-1"),
2714            Version::parse_lenient("1.2.3"),
2715            Version::parse_lenient("2024.01.15"),
2716            Version::parse_lenient("4.2.0rc1"),
2717            Version::parse_lenient("deadbeef"),
2718        ] {
2719            assert_eq!(rpm.partial_cmp_lenient(&other), None, "{other:?}");
2720            assert_eq!(other.partial_cmp_lenient(&rpm), None, "{other:?}");
2721        }
2722    }
2723
2724    #[test]
2725    fn parse_lenient_never_produces_the_rpm_variant() {
2726        for s in
2727            ECOSYSTEM_CORPUS
2728                .iter()
2729                .copied()
2730                .chain(["1.0^20200101", "1_0", "1.a", "5.1.8-2.fc35"])
2731        {
2732            assert!(
2733                !matches!(Version::parse_lenient(s), Version::Rpm { .. }),
2734                "{s}"
2735            );
2736        }
2737    }
2738
2739    /// Maven's documented "End Result Examples", one case per row.
2740    /// `expected` is how `a` orders relative to `b`.
2741    #[test]
2742    fn maven_documented_ordering_examples() {
2743        use Ordering::{Equal, Greater, Less};
2744
2745        for (a, b, expected) in [
2746            ("1", "1.1", Less),
2747            ("1-snapshot", "1", Less),
2748            ("1", "1-sp", Less),
2749            ("1-foo2", "1-foo10", Less),
2750            ("1.foo", "1-foo", Equal),
2751            ("1-foo", "1-1", Less),
2752            ("1-1", "1.1", Less),
2753            ("1.ga", "1-ga", Equal),
2754            ("1-ga", "1-0", Equal),
2755            ("1-0", "1_0", Equal),
2756            ("1_0", "1.0", Equal),
2757            ("1.0", "1", Equal),
2758            ("1-sp", "1-ga", Greater),
2759            ("1-sp.1", "1-ga.1", Greater),
2760            ("1-sp-1", "1-ga-1", Less),
2761            ("1-a1", "1-alpha-1", Equal),
2762            ("1.0-alpha1", "1.0-ALPHA1", Equal),
2763            ("1.7", "1.K", Greater),
2764            ("5.zebra", "5.aardvark", Greater),
2765            ("1.α", "1.b", Greater),
2766        ] {
2767            assert_eq!(maven_cmp(a, b), expected, "{a} vs {b}");
2768            assert_eq!(maven_cmp(b, a), expected.reverse(), "{b} vs {a}");
2769        }
2770    }
2771
2772    /// Maven's documented splitting and trimming examples: each row is a
2773    /// version and the spelling it reduces to.
2774    #[test]
2775    fn maven_documented_splitting_and_trimming_examples() {
2776        for (version, reduced) in [
2777            ("1-1.foo-bar1baz-.1", "1-1.foo-bar-1-baz-0.1"),
2778            ("1.0.0", "1"),
2779            ("1.ga", "1"),
2780            ("1.final", "1"),
2781            ("1.0", "1"),
2782            ("1.", "1"),
2783            ("1-", "1"),
2784            ("1_", "1"),
2785            ("1.0.0-foo.0.0", "1-foo"),
2786            ("1.0.0-0.0.0", "1"),
2787        ] {
2788            assert_eq!(maven_parse(version), maven_parse(reduced), "{version}");
2789        }
2790    }
2791
2792    #[test]
2793    fn maven_ranks_qualifiers_in_the_documented_order() {
2794        let ascending = [
2795            "1-alpha",
2796            "1-beta",
2797            "1-milestone",
2798            "1-rc",
2799            "1-snapshot",
2800            "1",
2801            "1-sp",
2802        ];
2803
2804        for (i, a) in ascending.iter().enumerate() {
2805            for b in &ascending[i + 1..] {
2806                assert_eq!(maven_cmp(a, b), Ordering::Less, "{a} vs {b}");
2807                assert_eq!(maven_cmp(b, a), Ordering::Greater, "{b} vs {a}");
2808            }
2809            // an unrecognized qualifier outranks every named one
2810            assert_eq!(maven_cmp(a, "1-zzz"), Ordering::Less, "{a} vs 1-zzz");
2811        }
2812        assert_eq!(maven_cmp("1-zzz", "1-aaa"), Ordering::Greater);
2813    }
2814
2815    #[test]
2816    fn maven_folds_qualifier_aliases() {
2817        for (a, b) in [
2818            ("1-cr", "1-rc"),
2819            ("1-cr1", "1-rc1"),
2820            ("1-ga", "1"),
2821            ("1-final", "1"),
2822            ("1-release", "1"),
2823            ("1-a1", "1-alpha1"),
2824            ("1-b2", "1-beta2"),
2825            ("1-m3", "1-milestone3"),
2826            ("1-RC1", "1-rc1"),
2827        ] {
2828            assert_eq!(maven_cmp(a, b), Ordering::Equal, "{a} vs {b}");
2829        }
2830
2831        // the one-letter shorthands expand only directly before a digit
2832        assert_eq!(maven_cmp("1-a", "1-alpha"), Ordering::Greater);
2833        assert_eq!(maven_cmp("1-a.1", "1-alpha.1"), Ordering::Greater);
2834    }
2835
2836    #[test]
2837    fn maven_folds_a_dotted_qualifier_to_the_hyphenated_form() {
2838        for (a, b) in [
2839            ("1.0.0.CR1", "1.0.0-RC1"),
2840            ("1.0.0.Final", "1.0.0"),
2841            ("1.0.0.GA", "1.0.0-ga"),
2842            ("2.0.0.Final", "2.0.0-Final"),
2843            ("1.0.0.Alpha1", "1.0.0-a1"),
2844            ("3.1.0.RELEASE", "3.1.0"),
2845        ] {
2846            assert_eq!(maven_cmp(a, b), Ordering::Equal, "{a} vs {b}");
2847        }
2848
2849        for (a, b) in [
2850            ("1.0.0.CR1", "1.0.0-CR2"),
2851            ("1.0.0.Alpha1", "1.0.0-RC1"),
2852            ("1.0.0.CR1", "1.0.0"),
2853            ("2.0.a", "2-1"),
2854            ("3.1.0.M1", "3.1.0-RC1"),
2855            ("1.0.0.Beta1", "1.0.0.CR1"),
2856        ] {
2857            assert_eq!(maven_cmp(a, b), Ordering::Less, "{a} vs {b}");
2858            assert_eq!(maven_cmp(b, a), Ordering::Greater, "{b} vs {a}");
2859        }
2860    }
2861
2862    /// JBoss, Spring, Hibernate and Netty, each rung also placed against the
2863    /// hyphenated spelling of its neighbours.
2864    #[test]
2865    fn maven_orders_the_published_dotted_ladders() {
2866        for ladder in [
2867            &[
2868                "1.0.0.Alpha1",
2869                "1.0.0-Beta1",
2870                "1.0.0.CR1",
2871                "1.0.0-CR2",
2872                "1.0.0.Final",
2873            ][..],
2874            &[
2875                "3.1.0.M1",
2876                "3.1.0-M2",
2877                "3.1.0.RC1",
2878                "3.1.0-RELEASE",
2879                "3.1.1.RELEASE",
2880            ][..],
2881            &["5.4.2.Final", "5.4.10.Final", "5.5.0.Alpha1", "5.5.0.Final"][..],
2882            &["4.1.9.Final", "4.1.65.Final", "4.1.65.1.Final"][..],
2883        ] {
2884            for (i, a) in ladder.iter().enumerate() {
2885                for b in &ladder[i + 1..] {
2886                    assert_eq!(maven_cmp(a, b), Ordering::Less, "{a} vs {b}");
2887                    assert_eq!(maven_cmp(b, a), Ordering::Greater, "{b} vs {a}");
2888                }
2889            }
2890        }
2891    }
2892
2893    #[test]
2894    fn maven_nests_a_qualifier_reached_past_an_item() {
2895        assert_eq!(maven_cmp("1-0.alpha", "1-alpha"), Ordering::Greater);
2896        assert_eq!(maven_cmp("1-0.beta", "1-0.alpha"), Ordering::Greater);
2897        assert_eq!(maven_cmp("1-0.alpha", "1-1"), Ordering::Less);
2898        assert_eq!(maven_cmp("1-0.alpha", "1"), Ordering::Less);
2899    }
2900
2901    #[test]
2902    fn maven_declines_a_version_nested_past_the_depth_cap() {
2903        let ok = format!("1{}", "-1".repeat(MAVEN_MAX_DEPTH - 2));
2904        let deep = format!("1{}", "-1".repeat(MAVEN_MAX_DEPTH));
2905
2906        assert!(super::maven_parse(&ok).is_some());
2907        assert!(super::maven_parse(&deep).is_none());
2908        assert_eq!(
2909            Version::parse_for_ecosystem(Some("maven"), &ok),
2910            Version::Maven(ok)
2911        );
2912        assert_eq!(
2913            Version::parse_for_ecosystem(Some("maven"), &deep),
2914            Version::Opaque(deep)
2915        );
2916    }
2917
2918    #[test]
2919    fn maven_declines_a_version_that_would_overflow_the_stack() {
2920        for deep in [format!("1{}", "-1".repeat(200_000)), "1a".repeat(200_000)] {
2921            assert_eq!(
2922                Version::parse_for_ecosystem(Some("maven"), &deep),
2923                Version::Opaque(deep.clone())
2924            );
2925            assert_eq!(
2926                Version::Maven(deep.clone()).partial_cmp_lenient(&Version::Maven(deep)),
2927                None
2928            );
2929        }
2930    }
2931
2932    #[test]
2933    fn maven_ordering_is_antisymmetric_and_transitive_on_the_documented_vectors() {
2934        const CORPUS: &[&str] = &[
2935            "1",
2936            "1.0",
2937            "1.1",
2938            "1-1",
2939            "1.foo",
2940            "1-foo",
2941            "1.bar",
2942            "1-bar",
2943            "1-alpha",
2944            "1-a1",
2945            "1-beta",
2946            "1-milestone",
2947            "1-rc",
2948            "1-cr",
2949            "1-snapshot",
2950            "1-ga",
2951            "1-sp",
2952            "1-sp.1",
2953            "1-sp-1",
2954            "1-ga-1",
2955            "1.0.0-foo.0.0",
2956            "1_0",
2957            "2",
2958            "1.0.1",
2959            "1.0-alpha1",
2960        ];
2961
2962        for a in CORPUS {
2963            for b in CORPUS {
2964                assert_eq!(
2965                    maven_cmp(a, b),
2966                    maven_cmp(b, a).reverse(),
2967                    "asymmetric: {a} vs {b}"
2968                );
2969                for c in CORPUS {
2970                    let (ab, bc) = (maven_cmp(a, b), maven_cmp(b, c));
2971                    if ab == bc || bc == Ordering::Equal {
2972                        assert_eq!(maven_cmp(a, c), ab, "intransitive: {a}, {b}, {c}");
2973                    }
2974                }
2975            }
2976        }
2977    }
2978
2979    #[test]
2980    fn maven_compares_numeric_tokens_beyond_u64() {
2981        assert_eq!(
2982            maven_cmp("1.99999999999999999999999", "1.99999999999999999999998"),
2983            Ordering::Greater
2984        );
2985        assert_eq!(maven_cmp("1.0000000000000000000001", "1.2"), Ordering::Less);
2986    }
2987
2988    /// the case the string-only path cannot order at all: `1.0-SNAPSHOT` reads
2989    /// as Debian and `1.0` as semver, and a mixed pair is `None`.
2990    #[test]
2991    fn maven_ecosystem_orders_a_snapshot_against_its_release() {
2992        assert_eq!(compare_versions("1.0-SNAPSHOT", "1.0"), None);
2993
2994        assert_eq!(
2995            compare_versions_for_ecosystem(Some("maven"), "1.0-SNAPSHOT", "1.0"),
2996            Some(Ordering::Less)
2997        );
2998        assert!(!is_version_downgrade_for_ecosystem(
2999            Some("maven"),
3000            "1.0-SNAPSHOT",
3001            "1.0"
3002        ));
3003        assert!(is_version_downgrade_for_ecosystem(
3004            Some("maven"),
3005            "1.0",
3006            "1.0-SNAPSHOT"
3007        ));
3008    }
3009
3010    #[test]
3011    fn maven_ecosystem_makes_previously_uncomparable_pairs_comparable() {
3012        use Ordering::{Equal, Greater, Less};
3013
3014        for (a, b, expected) in [
3015            ("1.0-SNAPSHOT", "1.0", Less),
3016            ("1.0-M1", "1.0", Less),
3017            ("1.0-sp1", "1.0", Greater),
3018            // a JBoss-style `.Final` release is the release itself
3019            ("2.0.0.Final", "2.0.0", Equal),
3020            ("1.0-cr1", "1.0-rc1", Equal),
3021            // `_` is outside the Debian alphabet, so a JDK version is opaque
3022            ("1.7.0_80", "1.7.0_79", Greater),
3023        ] {
3024            assert_eq!(compare_versions(a, b), None, "{a} vs {b}");
3025            assert_eq!(
3026                compare_versions_for_ecosystem(Some("maven"), a, b),
3027                Some(expected),
3028                "{a} vs {b}"
3029            );
3030            assert_eq!(
3031                compare_versions_for_ecosystem(Some("maven"), b, a),
3032                Some(expected.reverse()),
3033                "{b} vs {a}"
3034            );
3035        }
3036    }
3037
3038    #[test]
3039    fn maven_ecosystem_reverses_a_gate_the_string_only_path_fires_backwards() {
3040        // read as Debian revisions, `Final` sorts below `SNAPSHOT`; Maven ranks
3041        // the release above every snapshot
3042        assert_eq!(
3043            compare_versions("1.0-Final", "1.0-SNAPSHOT"),
3044            Some(Ordering::Less)
3045        );
3046        assert!(is_version_downgrade("1.0-SNAPSHOT", "1.0-Final"));
3047        assert!(!is_version_downgrade("1.0-Final", "1.0-SNAPSHOT"));
3048
3049        assert_eq!(
3050            compare_versions_for_ecosystem(Some("maven"), "1.0-Final", "1.0-SNAPSHOT"),
3051            Some(Ordering::Greater)
3052        );
3053        assert!(!is_version_downgrade_for_ecosystem(
3054            Some("maven"),
3055            "1.0-SNAPSHOT",
3056            "1.0-Final"
3057        ));
3058        assert!(is_version_downgrade_for_ecosystem(
3059            Some("maven"),
3060            "1.0-Final",
3061            "1.0-SNAPSHOT"
3062        ));
3063    }
3064
3065    #[test]
3066    fn maven_ecosystem_parses_as_maven() {
3067        assert_eq!(
3068            Version::parse_for_ecosystem(Some("maven"), "1.0-SNAPSHOT"),
3069            Version::Maven("1.0-SNAPSHOT".into())
3070        );
3071        for s in [
3072            "1.2.3",
3073            "1.0.0-alpha.1",
3074            "2.0.0.Final",
3075            "1.7.0_80",
3076            "1.0+b1",
3077        ] {
3078            assert!(
3079                matches!(
3080                    Version::parse_for_ecosystem(Some("maven"), s),
3081                    Version::Maven(_)
3082                ),
3083                "{s}"
3084            );
3085        }
3086    }
3087
3088    #[test]
3089    fn maven_ecosystem_match_ignores_case() {
3090        assert_eq!(
3091            Version::parse_for_ecosystem(Some("MAVEN"), "1.0-SNAPSHOT"),
3092            Version::parse_for_ecosystem(Some("maven"), "1.0-SNAPSHOT")
3093        );
3094    }
3095
3096    #[test]
3097    fn maven_ecosystem_keeps_codenames_and_hashes_opaque() {
3098        for s in [
3099            "deadbeef",
3100            "RELEASE",
3101            "LATEST",
3102            "",
3103            "master-SNAPSHOT",
3104            "1.0 ",
3105        ] {
3106            assert_eq!(
3107                Version::parse_for_ecosystem(Some("maven"), s),
3108                Version::Opaque(s.to_string()),
3109                "{s}"
3110            );
3111        }
3112    }
3113
3114    #[test]
3115    fn maven_ecosystem_strips_a_v_prefix_instead_of_skipping_the_pair() {
3116        assert!(matches!(
3117            Version::parse_for_ecosystem(Some("maven"), "v1.2.3"),
3118            Version::Maven(_)
3119        ));
3120        assert_eq!(
3121            compare_versions_for_ecosystem(Some("maven"), "v1.2.3", "v1.2.4"),
3122            Some(Ordering::Less)
3123        );
3124    }
3125
3126    #[test]
3127    fn maven_stays_uncomparable_against_every_other_parse_result() {
3128        let maven = Version::parse_for_ecosystem(Some("maven"), "1.2.3-1");
3129        for other in [
3130            Version::parse_for_ecosystem(Some("deb"), "1.2.3-1"),
3131            Version::parse_for_ecosystem(Some("rpm"), "1.2.3-1"),
3132            Version::parse_lenient("1.2.3"),
3133            Version::parse_lenient("2024.01.15"),
3134            Version::parse_lenient("4.2.0rc1"),
3135            Version::parse_lenient("deadbeef"),
3136        ] {
3137            assert_eq!(maven.partial_cmp_lenient(&other), None, "{other:?}");
3138            assert_eq!(other.partial_cmp_lenient(&maven), None, "{other:?}");
3139        }
3140    }
3141
3142    #[test]
3143    fn parse_lenient_never_produces_the_maven_variant() {
3144        for s in ECOSYSTEM_CORPUS.iter().copied().chain([
3145            "1.0-SNAPSHOT",
3146            "2.0.0.Final",
3147            "1.7.0_80",
3148            "1-sp",
3149        ]) {
3150            assert!(
3151                !matches!(Version::parse_lenient(s), Version::Maven(_)),
3152                "{s}"
3153            );
3154        }
3155    }
3156}