Skip to main content

dix_diff/
version.rs

1use std::{
2  cmp,
3  fmt,
4};
5
6use derive_more::{
7  Deref,
8  Display,
9};
10
11/// Separators used to split version strings.
12const SEPARATORS: &[char] = &['.', '-', '_', '+', '*', '=', '×', ' '];
13
14/// A version string with semantic comparison support.
15#[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  /// Iterate over components only.
42  pub fn components(&self) -> impl Iterator<Item = VersionComponent<'_>> {
43    Pieces::new(&self.name).filter_map(VersionPiece::component)
44  }
45
46  /// Iterate over all pieces (components and separators).
47  #[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/// Iterator over version pieces (components and separators).
174#[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/// Either a component or separator from a version string.
217#[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/// A single version component (numeric or text).
242#[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  // tests to ensure that [`Version::cmp`] is a total order
331  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      // a < b < c -> a < c
343      assert!(!(a < b && b < c) || a < c);
344      // a < c < b -> a < b
345      assert!(!(a < c && c < b) || a < b);
346      // b < a < c -> b < c
347      assert!(!(b < a && a < c) || b < c);
348      // b < c < a -> b < a
349      assert!(!(b < c && c < a) || b < a);
350      // c < a < b -> c < b
351      assert!(!(c < a && a < b) || c < b);
352      // c < b < a -> c < a
353      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")); // text > numeric
521    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); // 1, 2, 3, 4, 5, 6, 7
599    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"); // _ is a separator, so "1_beta" splits
611    assert_eq!(comps[4].0, "beta");
612    assert_eq!(comps[5].0, "1");
613    assert_eq!(comps[6].0, "x86"); // _ is a separator, so "x86_64" splits
614    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}