1use std::{
2 cmp,
3 fmt,
4};
5
6use derive_more::{
7 Deref,
8 Display,
9};
10
11const SEPARATORS: &[char] = &['.', '-', '_', '+', '*', '=', '×', ' '];
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
22pub struct Version {
23 pub name: String,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum VersionChangeOrdering {
28 Ordered(cmp::Ordering),
29 Unordered,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum ComparedVersionComponents {
34 Ordered(cmp::Ordering),
35 Equal,
36 Unordered,
37}
38
39impl Version {
40 #[must_use]
41 pub fn new(version: impl Into<String>) -> Self {
42 Self {
43 name: version.into(),
44 }
45 }
46
47 pub fn components(&self) -> impl Iterator<Item = VersionComponent<'_>> {
49 Pieces::new(&self.name).filter_map(VersionPiece::component)
50 }
51
52 #[must_use]
54 pub fn iter(&self) -> Pieces<'_> {
55 Pieces::new(&self.name)
56 }
57
58 pub(crate) fn change_ordering(&self, other: &Self) -> VersionChangeOrdering {
59 match self.compare_components_with(other, |self_comp, other_comp| {
60 if self_comp.is_git_hash_pair(other_comp) {
61 ComparedVersionComponents::Unordered
62 } else {
63 ComparedVersionComponents::Ordered(self_comp.cmp(&other_comp))
64 }
65 }) {
66 ComparedVersionComponents::Ordered(ordering) => {
67 VersionChangeOrdering::Ordered(ordering)
68 },
69 ComparedVersionComponents::Equal => {
70 VersionChangeOrdering::Ordered(cmp::Ordering::Equal)
71 },
72 ComparedVersionComponents::Unordered => VersionChangeOrdering::Unordered,
73 }
74 }
75
76 fn compare_components_with(
77 &self,
78 other: &Self,
79 mut compare_component: impl FnMut(
80 VersionComponent<'_>,
81 VersionComponent<'_>,
82 ) -> ComparedVersionComponents,
83 ) -> ComparedVersionComponents {
84 let self_comps: Vec<_> = self.components().collect();
85 let other_comps: Vec<_> = other.components().collect();
86 let mut saw_unordered = false;
87
88 for index in 0..self_comps.len().max(other_comps.len()) {
89 let self_comp = self_comps.get(index).copied();
90 let other_comp = other_comps.get(index).copied();
91
92 if self_comp == other_comp {
93 continue;
94 }
95
96 match compare_component_at(
97 index,
98 self_comp,
99 other_comp,
100 &mut compare_component,
101 ) {
102 ComparedVersionComponents::Equal
103 | ComparedVersionComponents::Ordered(cmp::Ordering::Equal) => {},
104 ComparedVersionComponents::Ordered(ordering) => {
105 return ComparedVersionComponents::Ordered(ordering);
106 },
107 ComparedVersionComponents::Unordered => {
108 saw_unordered = true;
109 },
110 }
111 }
112
113 if saw_unordered {
114 ComparedVersionComponents::Unordered
115 } else {
116 ComparedVersionComponents::Equal
117 }
118 }
119}
120
121fn compare_component_at(
122 index: usize,
123 self_comp: Option<VersionComponent<'_>>,
124 other_comp: Option<VersionComponent<'_>>,
125 compare_component: &mut impl FnMut(
126 VersionComponent<'_>,
127 VersionComponent<'_>,
128 ) -> ComparedVersionComponents,
129) -> ComparedVersionComponents {
130 let rank_ordering =
131 component_rank(index, self_comp).cmp(&component_rank(index, other_comp));
132
133 let (Some(self_comp), Some(other_comp)) = (self_comp, other_comp) else {
134 return ComparedVersionComponents::Ordered(rank_ordering);
135 };
136
137 match compare_component(self_comp, other_comp) {
138 ComparedVersionComponents::Unordered => {
139 ComparedVersionComponents::Unordered
140 },
141 ComparedVersionComponents::Equal
142 | ComparedVersionComponents::Ordered(cmp::Ordering::Equal) => {
143 ComparedVersionComponents::Equal
144 },
145 ComparedVersionComponents::Ordered(ordering) => {
146 ComparedVersionComponents::Ordered(rank_ordering.then(ordering))
147 },
148 }
149}
150
151fn component_rank(
152 index: usize,
153 component: Option<VersionComponent<'_>>,
154) -> ComponentRank {
155 match (index, component.map(|component| component.is_numeric())) {
158 (_, None) => ComponentRank::Missing,
159 (0, Some(true)) => ComponentRank::FirstNumeric,
160 (0, Some(false)) => ComponentRank::FirstText,
161 (_, Some(false)) => ComponentRank::SuffixText,
162 (_, Some(true)) => ComponentRank::SuffixNumeric,
163 }
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
167enum ComponentRank {
168 SuffixText,
169 Missing,
170 FirstNumeric,
171 FirstText,
172 SuffixNumeric,
173}
174
175impl<T: Into<String>> From<T> for Version {
176 fn from(s: T) -> Self {
177 Self::new(s)
178 }
179}
180
181impl PartialOrd for Version {
182 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
183 Some(self.cmp(other))
184 }
185}
186
187impl Ord for Version {
188 fn cmp(&self, other: &Self) -> cmp::Ordering {
189 let component_ordering =
190 match self.compare_components_with(other, |self_comp, other_comp| {
191 ComparedVersionComponents::Ordered(self_comp.cmp(&other_comp))
192 }) {
193 ComparedVersionComponents::Ordered(ordering) => ordering,
194 ComparedVersionComponents::Equal
195 | ComparedVersionComponents::Unordered => cmp::Ordering::Equal,
196 };
197
198 component_ordering.then_with(|| self.name.cmp(&other.name))
199 }
200}
201
202impl<'a> IntoIterator for &'a Version {
203 type Item = VersionPiece<'a>;
204 type IntoIter = Pieces<'a>;
205
206 fn into_iter(self) -> Self::IntoIter {
207 Pieces::new(&self.name)
208 }
209}
210
211#[derive(Clone, Copy)]
213pub struct Pieces<'a> {
214 remaining: &'a str,
215}
216
217impl<'a> Pieces<'a> {
218 const fn new(s: &'a str) -> Self {
219 Self { remaining: s }
220 }
221}
222
223#[expect(clippy::copy_iterator)]
224impl<'a> Iterator for Pieces<'a> {
225 type Item = VersionPiece<'a>;
226
227 fn next(&mut self) -> Option<Self::Item> {
228 if self.remaining.is_empty() {
229 return None;
230 }
231
232 let first = self.remaining.chars().next()?;
233
234 if SEPARATORS.contains(&first) {
235 let len = first.len_utf8();
236 let sep = &self.remaining[..len];
237 self.remaining = &self.remaining[len..];
238 return Some(VersionPiece::Separator(sep));
239 }
240
241 let len = self
242 .remaining
243 .chars()
244 .take_while(|c| !SEPARATORS.contains(c))
245 .map(char::len_utf8)
246 .sum();
247
248 let comp = &self.remaining[..len];
249 self.remaining = &self.remaining[len..];
250 Some(VersionPiece::Component(VersionComponent(comp)))
251 }
252}
253
254#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256pub enum VersionPiece<'a> {
257 Component(VersionComponent<'a>),
258 Separator(&'a str),
259}
260
261impl<'a> VersionPiece<'a> {
262 #[must_use]
263 pub const fn component(self) -> Option<VersionComponent<'a>> {
264 match self {
265 VersionPiece::Component(c) => Some(c),
266 VersionPiece::Separator(_) => None,
267 }
268 }
269
270 #[must_use]
271 pub const fn separator(self) -> Option<&'a str> {
272 match self {
273 VersionPiece::Component(_) => None,
274 VersionPiece::Separator(s) => Some(s),
275 }
276 }
277}
278
279#[derive(Display, Debug, Clone, Copy, Deref, PartialEq, Eq)]
281pub struct VersionComponent<'a>(&'a str);
282
283impl VersionComponent<'_> {
284 #[must_use]
285 pub fn is_numeric(&self) -> bool {
286 !self.0.is_empty() && self.0.bytes().all(|b| b.is_ascii_digit())
287 }
288
289 #[must_use]
290 pub fn as_u64(&self) -> Option<u64> {
291 self.is_numeric().then(|| self.0.parse().ok()).flatten()
292 }
293
294 fn is_git_hash_pair(&self, other: Self) -> bool {
295 is_hex_hash_component(self.0)
296 && is_hex_hash_component(other.0)
297 && (has_ascii_hex_letter(self.0) || has_ascii_hex_letter(other.0))
298 }
299}
300
301fn is_hex_hash_component(component: &str) -> bool {
302 (7..=40).contains(&component.len())
303 && component.bytes().all(|b| b.is_ascii_hexdigit())
304}
305
306fn has_ascii_hex_letter(component: &str) -> bool {
307 component
308 .bytes()
309 .any(|b| matches!(b, b'a'..=b'f' | b'A'..=b'F'))
310}
311
312impl PartialOrd for VersionComponent<'_> {
313 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
314 Some(self.cmp(other))
315 }
316}
317
318impl Ord for VersionComponent<'_> {
319 fn cmp(&self, other: &Self) -> cmp::Ordering {
320 match (self.is_numeric(), other.is_numeric()) {
321 (true, true) => {
322 match (self.as_u64(), other.as_u64()) {
323 (Some(a), Some(b)) => a.cmp(&b),
324 _ => self.0.cmp(other.0),
325 }
326 },
327 (false, false) => {
328 match (self.0, other.0) {
329 ("pre", _) => cmp::Ordering::Less,
330 (_, "pre") => cmp::Ordering::Greater,
331 _ => self.0.cmp(other.0),
332 }
333 },
334 (true, false) => cmp::Ordering::Less,
335 (false, true) => cmp::Ordering::Greater,
336 }
337 }
338}
339
340impl fmt::Display for Version {
341 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
342 f.write_str(&self.name)
343 }
344}
345
346impl fmt::Write for Version {
347 fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result {
348 fmt::write(&mut self.name, args)
349 }
350
351 fn write_str(&mut self, s: &str) -> fmt::Result {
352 self.name.push_str(s);
353 Ok(())
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use proptest::proptest;
360
361 use super::{
362 Version,
363 VersionChangeOrdering,
364 VersionComponent,
365 VersionPiece,
366 };
367
368 proptest! {
370 #[test]
371 fn test_version_transitivity(
372 a in ".*",
373 b in ".*",
374 c in ".*",
375 ) {
376 let a = Version::new(a);
377 let b = Version::new(b);
378 let c = Version::new(c);
379
380 assert!(!(a < b && b < c) || a < c);
382 assert!(!(a < c && c < b) || a < b);
384 assert!(!(b < a && a < c) || b < c);
386 assert!(!(b < c && c < a) || b < a);
388 assert!(!(c < a && a < b) || c < b);
390 assert!(!(c < b && b < a) || c < a);
392 }
393
394 #[test]
395 fn test_version_reflexivity(
396 a in ".*",
397 ) {
398 let a = Version::new(a);
399 let b = a.clone();
400
401 assert_eq!(a.cmp(&a), std::cmp::Ordering::Equal);
402 assert_eq!(a, b);
403 }
404
405 #[test]
406 fn test_version_antisymmetry(
407 a in ".*",
408 b in ".*",
409 ) {
410 let a = Version::new(a);
411 let b = Version::new(b);
412
413 assert_eq!(a.cmp(&b), b.cmp(&a).reverse());
414 if a.cmp(&b) == std::cmp::Ordering::Equal {
415 assert_eq!(a, b);
416 }
417 }
418
419 }
420
421 #[test]
422 fn version_transitivity_regression_for_empty_text_and_numeric_components() {
423 let empty = Version::new("");
424 let numeric = Version::new("0");
425 let text = Version::new(":");
426
427 assert!(empty < text);
428 assert!(numeric < text);
429 assert!(empty < numeric);
430 }
431
432 #[test]
433 fn version_suffix_order_is_transitive() {
434 let base = Version::new("1");
435 let text = Version::new("1-alpha");
436 let hyphen_numeric = Version::new("1-0");
437 let dot_numeric = Version::new("1.0");
438
439 assert!(text < base);
440 assert!(base < hyphen_numeric);
441 assert!(base < dot_numeric);
442 assert!(text < hyphen_numeric);
443 assert!(text < dot_numeric);
444 }
445
446 #[test]
447 fn version_order_keeps_component_equal_strings_distinct() {
448 use std::collections::BTreeSet;
449
450 let dotted = Version::new("1.0");
451 let hyphenated = Version::new("1-0");
452 let mut versions = BTreeSet::new();
453
454 versions.insert(dotted.clone());
455 versions.insert(hyphenated.clone());
456
457 assert_ne!(dotted, hyphenated);
458 assert_ne!(dotted.cmp(&hyphenated), std::cmp::Ordering::Equal);
459 assert_eq!(versions.len(), 2);
460 }
461
462 #[test]
463 fn change_ordering_treats_component_equal_strings_as_changed() {
464 let dotted = Version::new("1.0");
465 let hyphenated = Version::new("1-0");
466
467 assert_eq!(
468 dotted.change_ordering(&hyphenated),
469 VersionChangeOrdering::Ordered(std::cmp::Ordering::Equal),
470 );
471 assert_ne!(dotted.cmp(&hyphenated), std::cmp::Ordering::Equal);
472 }
473
474 #[test]
475 fn version_component_iter() {
476 let version = "132.1.2test234-1-man----.--.......---------..---";
477
478 assert_eq!(
479 Version::new(version)
480 .into_iter()
481 .filter_map(VersionPiece::component)
482 .collect::<Vec<_>>(),
483 [
484 VersionComponent("132"),
485 VersionComponent("1"),
486 VersionComponent("2test234"),
487 VersionComponent("1"),
488 VersionComponent("man")
489 ]
490 );
491 }
492
493 #[test]
494 fn version_comparison() {
495 assert!(Version::new("2.0.0") > Version::new("1.9.9"));
496 assert!(Version::new("2.1.0") > Version::new("2.0.9"));
497 assert!(Version::new("2.0.1") > Version::new("2.0.0"));
498 assert!(Version::new("1.0.0") > Version::new("1.0.0-pre"));
499 assert!(Version::new("1.0.0") > Version::new("1.0.0-alpha"));
500 assert!(Version::new("1.0.0-beta") > Version::new("1.0.0-alpha"));
501 assert!(Version::new("1.0.0-beta.11") > Version::new("1.0.0-beta.2"));
502 assert_eq!(Version::new("1.0.0"), Version::new("1.0.0"));
503 }
504
505 #[test]
506 fn change_ordering_treats_git_short_hash_pair_as_unordered() {
507 let old = Version::new("0.11.1-946aa34");
508 let new = Version::new("0.11.1-3564204");
509
510 assert_eq!(old.change_ordering(&new), VersionChangeOrdering::Unordered);
511 assert_eq!(new.change_ordering(&old), VersionChangeOrdering::Unordered);
512 }
513
514 #[test]
515 fn change_ordering_treats_git_long_hash_pair_as_unordered() {
516 let old = Version::new("0bf8387987c21bf2f8ed41d2575a8f22b139687f");
517 let new = Version::new("cd1931314beafeebc957964c65802961e283411e");
518
519 assert_eq!(old.change_ordering(&new), VersionChangeOrdering::Unordered);
520 assert_eq!(new.change_ordering(&old), VersionChangeOrdering::Unordered);
521 }
522
523 #[test]
524 fn change_ordering_uses_non_hash_components_before_hashes() {
525 let old = Version::new("25.05.31pre20250531_946aa34");
526 let new = Version::new("25.05.31pre20250601_3564204");
527
528 assert_eq!(
529 old.change_ordering(&new),
530 VersionChangeOrdering::Ordered(std::cmp::Ordering::Less),
531 );
532 assert_eq!(
533 new.change_ordering(&old),
534 VersionChangeOrdering::Ordered(std::cmp::Ordering::Greater),
535 );
536 }
537
538 #[test]
539 fn change_ordering_keeps_numeric_components_ordered() {
540 let old = Version::new("1.0-1234567");
541 let new = Version::new("1.0-2345678");
542
543 assert_eq!(
544 old.change_ordering(&new),
545 VersionChangeOrdering::Ordered(std::cmp::Ordering::Less),
546 );
547 }
548
549 #[test]
550 fn version_piece_iterator_includes_separators() {
551 let version = Version::new("1.2.3-alpha");
552 let pieces: Vec<_> = version.into_iter().collect();
553 assert_eq!(pieces.len(), 7);
554 assert!(matches!(pieces[0], VersionPiece::Component(_)));
555 assert!(matches!(pieces[1], VersionPiece::Separator(".")));
556 assert!(matches!(pieces[2], VersionPiece::Component(_)));
557 assert!(matches!(pieces[3], VersionPiece::Separator(".")));
558 assert!(matches!(pieces[4], VersionPiece::Component(_)));
559 assert!(matches!(pieces[5], VersionPiece::Separator("-")));
560 assert!(matches!(pieces[6], VersionPiece::Component(_)));
561 }
562
563 #[test]
564 fn version_piece_methods() {
565 let comp = VersionPiece::Component(VersionComponent("123"));
566 let sep = VersionPiece::Separator("-");
567
568 assert_eq!(comp.component(), Some(VersionComponent("123")));
569 assert_eq!(comp.separator(), None);
570 assert_eq!(sep.component(), None);
571 assert_eq!(sep.separator(), Some("-"));
572 }
573
574 #[test]
575 fn version_component_is_numeric() {
576 assert!(VersionComponent("123").is_numeric());
577 assert!(VersionComponent("0").is_numeric());
578 assert!(!VersionComponent("abc").is_numeric());
579 assert!(!VersionComponent("123abc").is_numeric());
580 assert!(!VersionComponent("").is_numeric());
581 assert!(!VersionComponent("12.3").is_numeric());
582 }
583
584 #[test]
585 fn version_component_as_u64() {
586 assert_eq!(VersionComponent("123").as_u64(), Some(123));
587 assert_eq!(VersionComponent("0").as_u64(), Some(0));
588 assert_eq!(
589 VersionComponent("18446744073709551615").as_u64(),
590 Some(u64::MAX)
591 );
592 assert_eq!(VersionComponent("abc").as_u64(), None);
593 assert_eq!(VersionComponent("123abc").as_u64(), None);
594 assert_eq!(VersionComponent("").as_u64(), None);
595 }
596
597 #[test]
598 fn component_comparison_numeric() {
599 assert!(VersionComponent("10") > VersionComponent("2"));
600 assert!(VersionComponent("2") < VersionComponent("10"));
601 assert_eq!(VersionComponent("5"), VersionComponent("5"));
602 }
603
604 #[test]
605 fn component_comparison_text() {
606 assert!(VersionComponent("beta") > VersionComponent("alpha"));
607 assert!(VersionComponent("rc") > VersionComponent("beta"));
608 assert_eq!(VersionComponent("stable"), VersionComponent("stable"));
609 }
610
611 #[test]
612 fn component_comparison_pre_special_case() {
613 assert!(VersionComponent("pre") < VersionComponent("alpha"));
614 assert!(VersionComponent("pre") > VersionComponent("1")); assert!(VersionComponent("alpha") > VersionComponent("pre"));
616 }
617
618 #[test]
619 fn component_comparison_mixed_types() {
620 assert!(VersionComponent("2") < VersionComponent("alpha"));
621 assert!(VersionComponent("alpha") > VersionComponent("2"));
622 }
623
624 #[test]
625 fn component_comparison_mixed_alphanumeric() {
626 assert_eq!(VersionComponent("2test234"), VersionComponent("2test234"));
627 assert!(VersionComponent("abc123") > VersionComponent("123abc"));
628 }
629
630 #[test]
631 fn version_comparison_equal_lengths() {
632 assert!(Version::new("1.2.3") > Version::new("1.2.2"));
633 assert!(Version::new("1.2.3") < Version::new("1.2.4"));
634 assert_eq!(Version::new("1.2.3"), Version::new("1.2.3"));
635 }
636
637 #[test]
638 fn version_comparison_suffix_edge_cases() {
639 assert!(Version::new("1.0.0") > Version::new("1.0.0-alpha"));
640 assert!(Version::new("1.0.0-alpha") < Version::new("1.0.0"));
641 assert!(Version::new("1.0.0-1") > Version::new("1.0.0"));
642 assert!(Version::new("1.0.0-alpha") < Version::new("1.0.0-1"));
643 assert!(Version::new("1.0.0-alpha") < Version::new("1.0.0-beta"));
644 assert!(Version::new("1.0.0-1") < Version::new("1.0.0-2"));
645 assert!(Version::new("1.0.0-9") < Version::new("1.0.0-10"));
646 }
647
648 #[test]
649 fn version_comparison_numeric_extensions() {
650 assert!(Version::new("1.0.0.1") > Version::new("1.0.0"));
651 assert!(Version::new("1.0.0") < Version::new("1.0.0.1"));
652 }
653
654 #[test]
655 fn version_display() {
656 let v1 = Version::new("1.2.3");
657 assert_eq!(format!("{v1}"), "1.2.3");
658 }
659
660 #[test]
661 fn version_write() {
662 use std::fmt::Write;
663
664 let mut v = Version::new("1.0");
665 write!(v, ".{}-beta", 2).unwrap();
666 assert_eq!(v.name, "1.0.2-beta");
667 }
668
669 #[test]
670 fn empty_version() {
671 let v = Version::new("");
672 assert_eq!(v.components().count(), 0);
673 }
674
675 #[test]
676 fn version_with_only_separators() {
677 let v = Version::new("...---___");
678 assert_eq!(v.components().count(), 0);
679 }
680
681 #[test]
682 fn version_from_string() {
683 let v1: Version = "1.2.3".into();
684 assert_eq!(v1.name, "1.2.3");
685
686 let v2: Version = String::from("4.5.6").into();
687 assert_eq!(v2.name, "4.5.6");
688 }
689
690 #[test]
691 fn various_separators() {
692 let v = Version::new("1_2+3=4*5×6 7");
693 let comps: Vec<_> = v.components().collect();
694 assert_eq!(comps.len(), 7); assert_eq!(comps[0].0, "1");
696 assert_eq!(comps[6].0, "7");
697 }
698
699 #[test]
700 fn complex_version_parsing() {
701 let v = Version::new("firefox-123.0.1_beta-1-x86_64");
702 let comps: Vec<_> = v.components().collect();
703 assert_eq!(comps[0].0, "firefox");
704 assert_eq!(comps[1].0, "123");
705 assert_eq!(comps[2].0, "0");
706 assert_eq!(comps[3].0, "1"); assert_eq!(comps[4].0, "beta");
708 assert_eq!(comps[5].0, "1");
709 assert_eq!(comps[6].0, "x86"); assert_eq!(comps[7].0, "64");
711 }
712
713 #[test]
714 fn version_clone_and_eq() {
715 let v1 = Version::new("1.0.0");
716 let v2 = v1.clone();
717 assert_eq!(v1, v2);
718 assert_eq!(v1.name, v2.name);
719 }
720
721 #[test]
722 fn version_hash_consistency() {
723 use std::collections::HashSet;
724
725 let v1 = Version::new("1.0.0");
726 let v2 = Version::new("1.0.0");
727 let v3 = Version::new("2.0.0");
728
729 let mut set = HashSet::new();
730 set.insert(v1);
731 assert!(set.contains(&v2));
732 assert!(!set.contains(&v3));
733 }
734}