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