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}