Skip to main content

deps_maven/
version.rs

1//! Maven version comparison and pre-release detection.
2
3use std::cmp::Ordering;
4
5/// Detects if a Maven version string is a pre-release.
6///
7/// A version is a pre-release when any of its segments is a qualifier that
8/// ranks below the release qualifier in `qualifier_rank` (`alpha`, `beta`,
9/// `milestone`/`M`, `rc`/`cr`, `snapshot`, ...) — the same table
10/// [`compare_versions`] uses for ordering, so this can never disagree with
11/// the comparator about whether a version is a base release or a
12/// pre-release.
13pub fn is_prerelease(version: &str) -> bool {
14    split_version(version).iter().any(|segment| {
15        if is_numeric_segment(segment) {
16            return false;
17        }
18        let tokens = tokenize_qualifier(segment);
19        let Some(QualToken::Alpha(prefix)) = tokens.first() else {
20            return false;
21        };
22        let has_numeric_suffix = matches!(tokens.get(1), Some(QualToken::Digits(_)));
23        qualifier_rank(&normalize_qualifier(prefix, has_numeric_suffix)) < qualifier_rank("")
24    })
25}
26
27/// Compares two Maven version strings by dot/dash-separated segment.
28///
29/// This is a total order (antisymmetric, transitive, and consistent with equality — verified by
30/// `test_compare_versions_total_order_invariants`), so it is safe to use as a `sort_by`
31/// comparator, e.g. sorting `maven-metadata.xml`'s version list in
32/// `crate::registry::parse_metadata_xml`. Each segment is classified as purely numeric (all
33/// ASCII digits) or a non-numeric qualifier. A numeric segment always outranks a non-numeric
34/// qualifier at the same position, which keeps legacy Maven identifiers such as Guava's bare
35/// `r03`..`r09` release tags below properly-formed numeric releases (e.g. `33.7.1-jre`). A
36/// missing segment (the shorter version ran out of components) is ranked against a non-numeric
37/// qualifier at that position by the same Maven qualifier precedence used for two real
38/// qualifiers (see `compare_qualifiers`): a version's own trailing dash-qualifier that ranks
39/// below release (e.g. `-RC1`, `-SNAPSHOT`) sorts below its base release (`6.1.0-RC1` < `6.1.0`),
40/// while one that ranks above release (e.g. `-sp`, or an unrecognized vendor suffix) sorts above
41/// it. Two numeric segments compare by magnitude (leading zeros ignored, no size limit); two real
42/// non-numeric segments are ranked by Maven qualifier precedence (see `compare_qualifiers`).
43///
44/// Note this does *not* treat a missing segment as equal to a present-but-zero one (`1.0` and
45/// `1.0.0` compare unequal here) — doing so would break the total order, since the zero-valued
46/// segment and the missing one can each compare differently against a qualifier at that position
47/// depending on which side of the pair supplies it. Range/interval bound matching, which does
48/// want that normalization (`[1.0]` should match `1.0.0`), uses the dedicated pairwise
49/// `compare_versions_for_range` instead.
50pub fn compare_versions(a: &str, b: &str) -> Ordering {
51    let a_parts = split_version(a);
52    let b_parts = split_version(b);
53
54    let max_len = a_parts.len().max(b_parts.len());
55    for i in 0..max_len {
56        let ap = a_parts.get(i).map_or("", |s| s.as_str());
57        let bp = b_parts.get(i).map_or("", |s| s.as_str());
58
59        let ord = compare_segment(ap, bp);
60        if ord != Ordering::Equal {
61            return ord;
62        }
63    }
64
65    Ordering::Equal
66}
67
68/// Compares two Maven version strings for range/interval bound matching only.
69///
70/// Unlike [`compare_versions`], a missing trailing segment (the shorter version ran out of
71/// components) normalizes as equal to a present-but-zero numeric segment at that position, per
72/// Maven's `IntItem.compareTo(null)` rule (the same rule [`compare_qualifiers`] already applies
73/// one level down, to qualifier-token digit runs): `1.0` == `1.0.0` == `1.0.0.0`. This is what
74/// lets a range bound with a different segment count than the version being checked still match
75/// correctly (`[1.0]` contains `1.0.0`; `[4.0,4.1]` contains `4.1.0`).
76///
77/// # Not a total order
78///
79/// This function is **not** transitive and must never be used as a `sort_by` comparator: because
80/// [`split_version`] flattens `.`/`-` into one flat segment list, a zero-valued segment and an
81/// absent one can each compare differently against a same-position qualifier depending on which
82/// version supplies it, producing ordering cycles (e.g. `1.0.0 > 1.0-jre`, `1.0-jre > 1.0`, but
83/// `1.0 == 1.0.0`). It is safe only for the pairwise range-containment checks in
84/// `crate::interval`, which never sort — see [`compare_versions`] for the total-order sorting
85/// comparator.
86pub(crate) fn compare_versions_for_range(a: &str, b: &str) -> Ordering {
87    let a_parts = split_version(a);
88    let b_parts = split_version(b);
89
90    let max_len = a_parts.len().max(b_parts.len());
91    for i in 0..max_len {
92        let ap = a_parts.get(i).map(String::as_str);
93        let bp = b_parts.get(i).map(String::as_str);
94
95        let ord = compare_segment_for_range(ap, bp);
96        if ord != Ordering::Equal {
97            return ord;
98        }
99    }
100
101    Ordering::Equal
102}
103
104fn split_version(v: &str) -> Vec<String> {
105    v.split(['.', '-'])
106        .map(|s| s.to_string())
107        .filter(|s| !s.is_empty())
108        .collect()
109}
110
111/// Compares a single dot/dash-separated version segment.
112///
113/// A purely numeric segment always outranks a non-numeric one: legacy Maven
114/// qualifiers such as Guava's bare `r03`..`r09` identifiers must sort below
115/// properly-formed numeric releases (e.g. `33.7.1-jre`), not above them via a
116/// raw ASCII string comparison (`'r' > '3'`). When neither segment is
117/// numeric, they are ranked as Maven qualifiers; see [`compare_qualifiers`].
118fn compare_segment(a: &str, b: &str) -> Ordering {
119    match (is_numeric_segment(a), is_numeric_segment(b)) {
120        (true, true) => compare_numeric_segments(a, b),
121        (true, false) => Ordering::Greater,
122        (false, true) => Ordering::Less,
123        (false, false) => compare_qualifiers(a, b),
124    }
125}
126
127/// Compares a single dot/dash-separated version segment for
128/// [`compare_versions_for_range`]; `None` means the shorter version ran out
129/// of components at this position.
130///
131/// When both segments are present, this defers to [`compare_segment`].
132/// When one side is missing, it applies Maven's `IntItem.compareTo(null)`
133/// rule: a present numeric segment that is all zeros (e.g. the third
134/// component of `1.0.0` vs `1.0`) is equivalent to a missing one, so the
135/// segments compare equal; any other numeric segment outranks the missing
136/// one. A present non-numeric qualifier is instead compared against the
137/// empty qualifier via [`compare_qualifiers`], preserving Maven qualifier
138/// precedence at a missing segment (e.g. `6.1.0-RC1` < `6.1.0`).
139fn compare_segment_for_range(a: Option<&str>, b: Option<&str>) -> Ordering {
140    match (a, b) {
141        (Some(a), Some(b)) => compare_segment(a, b),
142        (Some(a), None) if is_numeric_segment(a) => {
143            if is_zero_digits(a) {
144                Ordering::Equal
145            } else {
146                Ordering::Greater
147            }
148        }
149        (Some(a), None) => compare_qualifiers(a, ""),
150        (None, Some(_)) => compare_segment_for_range(b, a).reverse(),
151        (None, None) => Ordering::Equal,
152    }
153}
154
155/// A segment is numeric only if every byte is an ASCII digit; classifying by
156/// character class (rather than `str::parse::<u64>` success) means a segment
157/// with more than 20 digits is still treated as numeric instead of silently
158/// falling through to the non-numeric branch.
159fn is_numeric_segment(s: &str) -> bool {
160    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
161}
162
163/// Compares two all-digit segments by magnitude, ignoring leading zeros.
164///
165/// Digit strings of equal length compare identically whether by numeric
166/// value or by lexicographic byte order, so this avoids parsing into a
167/// fixed-width integer type and has no size limit.
168fn compare_numeric_segments(a: &str, b: &str) -> Ordering {
169    let a = a.trim_start_matches('0');
170    let b = b.trim_start_matches('0');
171    a.len().cmp(&b.len()).then_with(|| a.cmp(b))
172}
173
174/// Compares two non-numeric qualifier segments using Maven's
175/// `ComparableVersion` precedence: `alpha < beta < milestone < rc/cr <
176/// snapshot < (release, i.e. "", "ga", "final") < sp`, case-insensitively.
177///
178/// A missing segment is compared against `""` — padded by [`compare_versions`] itself when the
179/// other version simply runs out of components, or passed explicitly by
180/// [`compare_segment_for_range`] when the present side is a non-numeric qualifier
181/// (`split_version` never yields empty segments itself); `""` represents "no further
182/// qualifier", i.e. the release rank: a dash-qualifier segment ranking below release (`alpha`,
183/// `beta`, `milestone`, `rc`/`cr`, `snapshot`, e.g. `RC1` or `SNAPSHOT`) sorts below the base
184/// release it is compared against, while one ranking above release (`sp`, or any unrecognized
185/// qualifier such as a vendor suffix) sorts above it — the same per-token rank comparison used
186/// for two real qualifiers, not a special case.
187///
188/// Both segments are tokenized into maximal alpha/digit runs (see
189/// [`tokenize_qualifier`]) and compared positionally, token by token,
190/// returning the first non-equal ordering — matching Maven's
191/// `ComparableVersion`, which splits a qualifier on every alpha/digit
192/// transition rather than just the trailing one (e.g. `rc1a` becomes `rc`,
193/// `1`, `a`), so a leading unrecognized run like `a` in `rc1a` never
194/// overrides the qualifier rank carried by the leading `rc` token. Each
195/// position is compared as follows:
196/// - alpha vs alpha: ranked by Maven qualifier precedence (see
197///   [`qualifier_rank`]); segments that are not recognized qualifier words
198///   rank above every known qualifier, including `sp`, matching
199///   `ComparableVersion`, which compares an unrecognized qualifier's index
200///   (`QUALIFIERS.size()`) as a string against the known indices, so it
201///   always sorts last.
202/// - digits vs digits: compared numerically rather than lexicographically
203///   (e.g. `M2` vs `M10`, `alpha9` vs `alpha15`).
204/// - digits vs alpha at the same position: digits always outrank alpha,
205///   mirroring the numeric-outranks-non-numeric rule [`compare_segment`]
206///   applies at the top level.
207/// - alpha vs a missing token (one side ran out): ranked against the empty
208///   qualifier, same as the alpha-vs-alpha case with `""` on the missing
209///   side.
210/// - digits vs a missing token: a present-but-zero digit run (e.g. `r0` vs
211///   `r`) is treated as equivalent to a missing one, mirroring Maven's
212///   `IntItem.compareTo(null)`, which returns `0` for a zero-valued item
213///   compared against an absent one; any other digit run outranks a missing
214///   token.
215fn compare_qualifiers(a: &str, b: &str) -> Ordering {
216    let a_tokens = tokenize_qualifier(a);
217    let b_tokens = tokenize_qualifier(b);
218    let max_len = a_tokens.len().max(b_tokens.len());
219
220    for i in 0..max_len {
221        let ord = compare_qualifier_tokens(
222            a_tokens.get(i),
223            a_tokens.get(i + 1),
224            b_tokens.get(i),
225            b_tokens.get(i + 1),
226        );
227        if ord != Ordering::Equal {
228            return ord;
229        }
230    }
231
232    Ordering::Equal
233}
234
235/// Compares a single positional pair of qualifier tokens; `a_next`/`b_next`
236/// are the tokens immediately following each, needed to decide
237/// `has_numeric_suffix` when normalizing an `Alpha` token.
238fn compare_qualifier_tokens(
239    a: Option<&QualToken<'_>>,
240    a_next: Option<&QualToken<'_>>,
241    b: Option<&QualToken<'_>>,
242    b_next: Option<&QualToken<'_>>,
243) -> Ordering {
244    match (a, b) {
245        (Some(QualToken::Alpha(p)), Some(QualToken::Alpha(q))) => {
246            let a_norm = normalize_qualifier(p, matches!(a_next, Some(QualToken::Digits(_))));
247            let b_norm = normalize_qualifier(q, matches!(b_next, Some(QualToken::Digits(_))));
248            qualifier_rank(&a_norm)
249                .cmp(&qualifier_rank(&b_norm))
250                .then_with(|| a_norm.cmp(&b_norm))
251        }
252        (Some(QualToken::Digits(p)), Some(QualToken::Digits(q))) => compare_numeric_segments(p, q),
253        (Some(QualToken::Digits(_)), Some(QualToken::Alpha(_))) => Ordering::Greater,
254        (Some(QualToken::Alpha(_)), Some(QualToken::Digits(_))) => Ordering::Less,
255        (Some(QualToken::Alpha(p)), None) => {
256            let a_norm = normalize_qualifier(p, matches!(a_next, Some(QualToken::Digits(_))));
257            qualifier_rank(&a_norm).cmp(&qualifier_rank(""))
258        }
259        (None, Some(QualToken::Alpha(q))) => {
260            let b_norm = normalize_qualifier(q, matches!(b_next, Some(QualToken::Digits(_))));
261            qualifier_rank("").cmp(&qualifier_rank(&b_norm))
262        }
263        (Some(QualToken::Digits(p)), None) => {
264            if is_zero_digits(p) {
265                Ordering::Equal
266            } else {
267                Ordering::Greater
268            }
269        }
270        (None, Some(QualToken::Digits(q))) => {
271            if is_zero_digits(q) {
272                Ordering::Equal
273            } else {
274                Ordering::Less
275            }
276        }
277        (None, None) => Ordering::Equal,
278    }
279}
280
281/// Whether a digit string's value is zero (all-zero, including `"0"`,
282/// `"00"`, or empty — the latter cannot occur from [`tokenize_qualifier`]
283/// but is handled the same way for safety).
284fn is_zero_digits(digits: &str) -> bool {
285    digits.bytes().all(|b| b == b'0')
286}
287
288/// A maximal alpha or digit run produced by [`tokenize_qualifier`].
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290enum QualToken<'a> {
291    Alpha(&'a str),
292    Digits(&'a str),
293}
294
295/// Splits a qualifier into maximal alternating alpha/digit runs, at every
296/// ASCII-digit/non-digit boundary (e.g. `"rc1a"` -> `[Alpha("rc"),
297/// Digits("1"), Alpha("a")]`, `"M10"` -> `[Alpha("M"), Digits("10")]`,
298/// `"beta"` -> `[Alpha("beta")]`). Either kind may appear first; only called
299/// on segments already known to be non-numeric, so the result is never
300/// empty. Mirrors Maven's `ComparableVersion` tokenizer, which splits a
301/// qualifier on every alpha/digit transition, not just the trailing one.
302fn tokenize_qualifier(s: &str) -> Vec<QualToken<'_>> {
303    let mut tokens = Vec::new();
304    let bytes = s.as_bytes();
305    let mut start = 0;
306    while start < bytes.len() {
307        let is_digit = bytes[start].is_ascii_digit();
308        let end = bytes[start..]
309            .iter()
310            .position(|b| b.is_ascii_digit() != is_digit)
311            .map_or(bytes.len(), |i| start + i);
312        let run = &s[start..end];
313        tokens.push(if is_digit {
314            QualToken::Digits(run)
315        } else {
316            QualToken::Alpha(run)
317        });
318        start = end;
319    }
320    tokens
321}
322
323/// Lowercases a qualifier prefix and resolves it to Maven's canonical
324/// qualifier name, applying the aliases from `ComparableVersion`: `cr` ->
325/// `rc` and `ga`/`final`/`release` -> the empty (release) qualifier
326/// unconditionally, plus the single-letter `a` -> `alpha`, `b` -> `beta`,
327/// `m` -> `milestone` aliases — but only when `has_numeric_suffix` is set
328/// (i.e. the prefix was glued to a trailing number, e.g. `M2`). A bare `1.0-m`
329/// with no digit after it stays an unrecognized qualifier, matching Maven's
330/// tokenizer, which only folds a single letter into its word alias when it
331/// is immediately followed by a digit.
332fn normalize_qualifier(prefix: &str, has_numeric_suffix: bool) -> String {
333    let lower = prefix.to_ascii_lowercase();
334    if has_numeric_suffix && lower.len() == 1 {
335        match lower.as_str() {
336            "a" => return "alpha".to_string(),
337            "b" => return "beta".to_string(),
338            "m" => return "milestone".to_string(),
339            _ => {}
340        }
341    }
342    match lower.as_str() {
343        "cr" => "rc".to_string(),
344        "ga" | "final" | "release" => String::new(),
345        _ => lower,
346    }
347}
348
349/// Ranks a normalized qualifier per Maven's `ComparableVersion.QUALIFIERS`
350/// table (`alpha, beta, milestone, rc, snapshot, "", sp`). An unrecognized
351/// qualifier ranks above all of them, including `sp`: `ComparableVersion`
352/// compares an unknown qualifier's index (`QUALIFIERS.size()`, i.e. one past
353/// `sp`) as a string against the known single-digit indices, so it always
354/// sorts last.
355fn qualifier_rank(qualifier: &str) -> u8 {
356    match qualifier {
357        "alpha" => 0,
358        "beta" => 1,
359        "milestone" => 2,
360        "rc" => 3,
361        "snapshot" => 4,
362        "" => 5,
363        "sp" => 6,
364        _ => 7,
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_prerelease_detection() {
374        assert!(is_prerelease("1.0.0-SNAPSHOT"));
375        assert!(is_prerelease("1.0.0-alpha"));
376        assert!(is_prerelease("1.0.0-ALPHA"));
377        assert!(is_prerelease("1.0.0-beta"));
378        assert!(is_prerelease("1.0.0-rc1"));
379        assert!(is_prerelease("1.0.0-RC1"));
380        assert!(is_prerelease("2.0.0-M1"));
381        assert!(is_prerelease("2.0.0-M10"));
382        assert!(is_prerelease("1.0.0-rc1a"));
383    }
384
385    #[test]
386    fn test_stable_versions() {
387        assert!(!is_prerelease("1.0.0"));
388        assert!(!is_prerelease("3.14.0"));
389        assert!(!is_prerelease("1.2.3.Final"));
390        assert!(!is_prerelease("2.0.RELEASE"));
391    }
392
393    #[test]
394    fn test_version_comparison() {
395        assert_eq!(compare_versions("1.0.0", "1.0.0"), Ordering::Equal);
396        assert_eq!(compare_versions("1.0.1", "1.0.0"), Ordering::Greater);
397        assert_eq!(compare_versions("1.0.0", "1.0.1"), Ordering::Less);
398        assert_eq!(compare_versions("2.0.0", "1.9.9"), Ordering::Greater);
399        assert_eq!(compare_versions("10.0.0", "9.0.0"), Ordering::Greater);
400    }
401
402    #[test]
403    fn test_exact_match() {
404        assert_eq!(compare_versions("3.14.0", "3.14.0"), Ordering::Equal);
405    }
406
407    #[test]
408    fn test_numeric_release_outranks_bare_qualifier() {
409        // Guava scenario: r03-r09 are legacy bare qualifiers that must not
410        // outrank properly-formed numeric releases.
411        assert_eq!(compare_versions("33.7.1-jre", "r09"), Ordering::Greater);
412        assert_eq!(compare_versions("r09", "33.7.1-jre"), Ordering::Less);
413        assert_eq!(compare_versions("14.0", "r09"), Ordering::Greater);
414    }
415
416    #[test]
417    fn test_bare_qualifiers_ordered_relative_to_each_other() {
418        assert_eq!(compare_versions("r09", "r03"), Ordering::Greater);
419        assert_eq!(compare_versions("r03", "r09"), Ordering::Less);
420        assert_eq!(compare_versions("r05", "r05"), Ordering::Equal);
421        // "r0" has a present-but-zero numeric suffix, equivalent to a
422        // missing one, matching Maven's IntItem.compareTo(null).
423        assert_eq!(compare_versions("r0", "r"), Ordering::Equal);
424        assert_eq!(compare_versions("r00", "r"), Ordering::Equal);
425        assert_eq!(compare_versions("r1", "r"), Ordering::Greater);
426    }
427
428    #[test]
429    fn test_numeric_segment_outranks_qualifier_mid_version() {
430        // "1.0-final" has a non-numeric third segment; a numeric segment at
431        // the same position must still outrank it.
432        assert_eq!(compare_versions("1.0.1", "1.0-final"), Ordering::Greater);
433    }
434
435    #[test]
436    fn test_prerelease_sorts_below_own_base_release() {
437        // junit-jupiter maven-metadata.xml case. "M1" < "RC1" here follows
438        // the milestone < rc qualifier rank, not coincidental ASCII order;
439        // both real qualifiers sort below the missing-segment base release.
440        assert_eq!(compare_versions("6.1.0-M1", "6.1.0-RC1"), Ordering::Less);
441        assert_eq!(compare_versions("6.1.0-RC1", "6.1.0"), Ordering::Less);
442        assert_eq!(compare_versions("6.1.0-M1", "6.1.0"), Ordering::Less);
443        assert_eq!(compare_versions("6.1.0", "6.1.0-RC1"), Ordering::Greater);
444    }
445
446    #[test]
447    fn test_prerelease_sort_matches_junit_jupiter_metadata_order() {
448        // Real maven-metadata.xml version list order for junit-jupiter: both
449        // qualifiers sort below the base release via `.sort_by`, not just in
450        // isolated two-way comparisons.
451        let mut versions = vec!["6.1.0", "6.1.0-M1", "6.1.0-RC1"];
452        versions.sort_by(|a, b| compare_versions(a, b));
453        assert_eq!(versions, vec!["6.1.0-M1", "6.1.0-RC1", "6.1.0"]);
454    }
455
456    #[test]
457    fn test_prerelease_ordering_independent_of_segment_count() {
458        // "1.0-SNAPSHOT" vs "1.0" (padding) must agree with the already
459        // component-count-matched "1.0-SNAPSHOT" vs "1.0.0" comparison.
460        assert_eq!(compare_versions("1.0-SNAPSHOT", "1.0"), Ordering::Less);
461        assert_eq!(compare_versions("1.0-SNAPSHOT", "1.0.0"), Ordering::Less);
462    }
463
464    #[test]
465    fn test_numeric_segment_beyond_u64_range() {
466        // A 20-digit segment overflows u64::MAX (20 digits) but must still be
467        // classified and compared as numeric, not fall through to the
468        // non-numeric lexicographic branch.
469        assert_eq!(
470            compare_versions("1.99999999999999999999", "1.2"),
471            Ordering::Greater
472        );
473        assert_eq!(
474            compare_versions("1.100000000000000000000", "1.99999999999999999999"),
475            Ordering::Greater
476        );
477        assert_eq!(compare_versions("1.007", "1.07"), Ordering::Equal);
478    }
479
480    #[test]
481    fn test_glued_numeric_qualifier_compares_numerically() {
482        // #130: a letter prefix glued directly to a multi-digit number must
483        // compare the numeric suffix by magnitude, not by raw ASCII bytes.
484        assert_eq!(compare_versions("6.1.0-M2", "6.1.0-M10"), Ordering::Less);
485        assert_eq!(compare_versions("6.1.0-M10", "6.1.0-M2"), Ordering::Greater);
486        assert_eq!(compare_versions("6.1.0-RC2", "6.1.0-RC10"), Ordering::Less);
487        assert_eq!(
488            compare_versions("1.0.alpha9", "1.0.alpha15"),
489            Ordering::Less
490        );
491    }
492
493    #[test]
494    fn test_vaadin_alpha_sequence_orders_numerically() {
495        // Vaadin publishes .alpha1..alpha15 in one maven-metadata.xml; under
496        // lexicographic comparison alpha9 > alpha15, which is wrong.
497        assert_eq!(
498            compare_versions("1.0.alpha9", "1.0.alpha15"),
499            Ordering::Less
500        );
501        assert_eq!(
502            compare_versions("1.0.alpha15", "1.0.alpha1"),
503            Ordering::Greater
504        );
505    }
506
507    #[test]
508    fn test_qualifier_precedence_table() {
509        // #131: rc/cr outranks beta, beta is below milestone/M, matching
510        // Maven's ComparableVersion.QUALIFIERS precedence.
511        assert_eq!(compare_versions("1.0-RC1", "1.0-beta"), Ordering::Greater);
512        assert_eq!(compare_versions("1.0-beta", "1.0-M1"), Ordering::Less);
513        assert_eq!(compare_versions("1.0-rc1", "1.0-RC1"), Ordering::Equal);
514    }
515
516    #[test]
517    fn test_qualifier_precedence_full_chain() {
518        // alpha < beta < milestone < rc < snapshot < release < sp, with `cr`
519        // aliased to `rc` and `ga`/`final` aliased to the release qualifier.
520        assert_eq!(compare_versions("9.9-alpha", "9.9-beta"), Ordering::Less);
521        assert_eq!(
522            compare_versions("9.9-beta", "9.9-milestone"),
523            Ordering::Less
524        );
525        assert_eq!(compare_versions("9.9-milestone", "9.9-rc"), Ordering::Less);
526        assert_eq!(compare_versions("9.9-rc", "9.9-cr"), Ordering::Equal);
527        assert_eq!(compare_versions("9.9-rc", "9.9-snapshot"), Ordering::Less);
528        assert_eq!(compare_versions("9.9-snapshot", "9.9-ga"), Ordering::Less);
529        assert_eq!(compare_versions("9.9-ga", "9.9-final"), Ordering::Equal);
530        assert_eq!(compare_versions("9.9-ga", "9.9-sp"), Ordering::Less);
531    }
532
533    #[test]
534    fn test_unknown_qualifier_ranks_above_release_and_sp() {
535        // Matches Maven: an unrecognized qualifier's index compares as the
536        // string "7-word" against the known single-digit indices, so it
537        // always sorts after every known qualifier, sp included.
538        assert_eq!(compare_versions("9.9-ga", "9.9-vaadin"), Ordering::Less);
539        assert_eq!(compare_versions("9.9-vaadin", "9.9-sp"), Ordering::Greater);
540        assert_eq!(compare_versions("9.9-foo", "9.9-vaadin"), Ordering::Less);
541    }
542
543    #[test]
544    fn test_single_letter_qualifier_aliases_gated_on_trailing_digit() {
545        // Maven aliases a/b/m to alpha/beta/milestone only when the letter
546        // is immediately followed by a digit (the same alpha/digit token
547        // boundary #130 already computes); a bare letter with no digit stays
548        // an unrecognized qualifier instead of silently matching the word.
549        assert_eq!(compare_versions("1.0-a1", "1.0-alpha1"), Ordering::Equal);
550        assert_eq!(compare_versions("1.0-a1", "1.0-beta1"), Ordering::Less);
551        assert_eq!(compare_versions("1.0-b1", "1.0-beta1"), Ordering::Equal);
552        assert_eq!(
553            compare_versions("1.0-m1", "1.0-milestone1"),
554            Ordering::Equal
555        );
556        assert_eq!(
557            compare_versions("1.0-m", "1.0-milestone"),
558            Ordering::Greater
559        );
560    }
561
562    #[test]
563    fn test_non_trailing_digit_run_uses_leading_qualifier_prefix() {
564        // The qualifier tokenizer splits on every alpha/digit transition, not
565        // just the trailing one, so "rc1a" becomes ["rc", "1", "a"] and is
566        // ranked by its leading "rc" token, not treated as one unrecognized
567        // unit.
568        assert_eq!(compare_versions("1.0-rc1a", "1.0-rc"), Ordering::Greater);
569        assert_eq!(compare_versions("1.0-rc1a", "1.0-sp"), Ordering::Less);
570        assert_eq!(compare_versions("1.0-rc1a", "1.0-rc1"), Ordering::Greater);
571        assert_eq!(
572            compare_versions("1.0-alpha2beta", "1.0-alpha2"),
573            Ordering::Less
574        );
575        assert_eq!(
576            compare_versions("1.0-alpha2beta", "1.0-alpha3"),
577            Ordering::Less
578        );
579        // Mismatched token kind at the same position: digits always outrank
580        // alpha, mirroring the top-level numeric-outranks-non-numeric rule.
581        assert_eq!(
582            compare_versions("1.0-2beta", "1.0-beta2"),
583            Ordering::Greater
584        );
585        assert_eq!(compare_versions("1.0-beta2", "1.0-2beta"), Ordering::Less);
586        // Deeper chain: exercises numeric comparison at token index 3.
587        assert_eq!(compare_versions("1.0-rc1a2", "1.0-rc1a10"), Ordering::Less);
588    }
589
590    #[test]
591    fn test_qualifier_missing_segment_ranked_by_token_rank_not_shortcut() {
592        // A missing segment pads to rank(""), the same per-token rank used
593        // for two real qualifiers: only qualifiers below release rank
594        // (alpha/beta/milestone/rc/snapshot) lose to the missing segment.
595        // `sp` and unrecognized qualifiers rank above release, so they
596        // outrank a missing segment too — there is no blanket rule that a
597        // missing segment always outranks every real qualifier.
598        assert_eq!(compare_versions("1.0-sp", "1.0"), Ordering::Greater);
599        assert_eq!(compare_versions("1.0-ga", "1.0"), Ordering::Equal);
600        assert_eq!(compare_versions("1.0-vaadin", "1.0"), Ordering::Greater);
601        assert_eq!(compare_versions("9.9", "9.9-vaadin"), Ordering::Less);
602    }
603
604    #[test]
605    fn test_compare_versions_for_range_normalizes_trailing_zero_segments() {
606        // #182, range/interval bound matching only: a missing trailing segment
607        // normalizes as equal to a zero-valued numeric segment, matching
608        // Maven's IntItem.compareTo(null). compare_versions itself must NOT do
609        // this — see test_compare_versions_does_not_normalize_trailing_zero_segments
610        // and test_compare_versions_total_order_invariants (C1 regression).
611        assert_eq!(compare_versions_for_range("1.0", "1.0.0"), Ordering::Equal);
612        assert_eq!(compare_versions_for_range("1.0.0", "1.0"), Ordering::Equal);
613        assert_eq!(
614            compare_versions_for_range("1.0", "1.0.0.0"),
615            Ordering::Equal
616        );
617        assert_eq!(compare_versions_for_range("1", "1.0.0"), Ordering::Equal);
618        assert_eq!(compare_versions_for_range("1.0.00", "1.0"), Ordering::Equal);
619        assert_eq!(
620            compare_versions_for_range("1.0.1", "1.0"),
621            Ordering::Greater
622        );
623        assert_eq!(compare_versions_for_range("1.0", "1.0.1"), Ordering::Less);
624    }
625
626    #[test]
627    fn test_compare_versions_for_range_normalization_does_not_affect_qualifiers() {
628        // A missing segment must still lose to a present non-numeric
629        // qualifier per Maven qualifier precedence, not be swallowed by the
630        // zero-normalization rule (#182).
631        assert_eq!(
632            compare_versions_for_range("6.1.0-RC1", "6.1.0"),
633            Ordering::Less
634        );
635        assert_eq!(
636            compare_versions_for_range("1.0-sp", "1.0"),
637            Ordering::Greater
638        );
639        assert_eq!(
640            compare_versions_for_range("1.0.0-SNAPSHOT", "1.0"),
641            Ordering::Less
642        );
643    }
644
645    #[test]
646    fn test_compare_versions_does_not_normalize_trailing_zero_segments() {
647        // compare_versions must stay a total order for sort_by callers
648        // (crate::registry::parse_metadata_xml). Unlike
649        // compare_versions_for_range, a missing trailing segment is ranked as
650        // an empty qualifier here, not treated as equal to a zero-valued
651        // numeric one.
652        assert_eq!(compare_versions("1.0", "1.0.0"), Ordering::Less);
653        assert_eq!(compare_versions("1.0.0", "1.0"), Ordering::Greater);
654    }
655
656    #[test]
657    fn test_compare_versions_total_order_invariants() {
658        // C1 regression guard: a version corpus mixing segment-count spellings
659        // of the same release with a same-base above-release qualifier used to
660        // produce ordering cycles (1.0.0 > 1.0-jre, 1.0-jre > 1.0, 1.0 == 1.0.0)
661        // once compare_versions treated a missing segment as zero. Antisymmetry,
662        // transitivity of `<`, and equal-substitution must all hold, or
663        // `Vec::sort_by` panics ("does not correctly implement a total order")
664        // on realistic maven-metadata.xml version lists (crate::registry).
665        let corpus = [
666            "1.0",
667            "1.0.0",
668            "1.0.0.0",
669            "1.0-jre",
670            "1.0-android",
671            "1.0-sp",
672            "1.0-RC1",
673            "1.0-SNAPSHOT",
674            "1.0.1",
675            "1.1",
676            "1.1.0",
677            "2.0",
678        ];
679        for &a in &corpus {
680            for &b in &corpus {
681                assert_eq!(
682                    compare_versions(a, b),
683                    compare_versions(b, a).reverse(),
684                    "antisymmetry: compare({a}, {b}) vs compare({b}, {a})"
685                );
686                for &c in &corpus {
687                    if compare_versions(a, b) == Ordering::Less
688                        && compare_versions(b, c) == Ordering::Less
689                    {
690                        assert_eq!(
691                            compare_versions(a, c),
692                            Ordering::Less,
693                            "transitivity: {a} < {b} < {c} but not {a} < {c}"
694                        );
695                    }
696                    if compare_versions(a, b) == Ordering::Equal {
697                        assert_eq!(
698                            compare_versions(a, c),
699                            compare_versions(b, c),
700                            "equal-substitution: {a} == {b} but compare({a},{c}) != compare({b},{c})"
701                        );
702                    }
703                }
704            }
705        }
706    }
707}