Skip to main content

vcs_diff/
version.rs

1//! A semantic `major.minor.patch` version and a tolerant parser for the
2//! `<tool> --version` banners that `vcs-git`/`vcs-jj` read.
3
4/// A parsed CLI version (`major.minor.patch`). `Ord` compares numerically, so a
5/// caller can gate a feature on a minimum version; `Hash` lets it key a map (e.g.
6/// a per-version capability cache).
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize))]
9pub struct Version {
10    /// Major component (`2` in `2.54.0`).
11    pub major: u64,
12    /// Minor component.
13    pub minor: u64,
14    /// Patch component (`0` when the binary reports only `major.minor`).
15    pub patch: u64,
16}
17
18impl std::fmt::Display for Version {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
21    }
22}
23
24/// Find the first `N.N[.N…]` token in `raw` and return its leading three numeric
25/// components (a missing patch reads as 0). Each component is the token's leading
26/// digits, so `0-dev` or `1.windows` trailers don't break parsing — this handles
27/// `git version 2.54.0.windows.1`, `jj 0.42.0`, `2.41.0-rc1`, etc.
28pub fn parse_dotted_version(raw: &str) -> Option<Version> {
29    for token in raw.split_whitespace() {
30        let mut parts = token.split('.');
31        let Some(major) = parts.next().and_then(leading_number) else {
32            continue;
33        };
34        let Some(minor) = parts.next().and_then(leading_number) else {
35            continue; // A bare number ("2") is not a version token.
36        };
37        let patch = parts.next().and_then(leading_number).unwrap_or(0);
38        return Some(Version {
39            major,
40            minor,
41            patch,
42        });
43    }
44    None
45}
46
47/// The numeric prefix of `s` (`"38-dev"` → 38); `None` when it has none.
48fn leading_number(s: &str) -> Option<u64> {
49    let end = s.bytes().take_while(u8::is_ascii_digit).count();
50    if end == 0 {
51        return None;
52    }
53    s[..end].parse().ok()
54}
55
56#[cfg(test)]
57mod tests {
58    use super::*;
59
60    #[test]
61    fn parses_real_world_shapes() {
62        // The Windows build trailer (`.windows.1`) is extra dotted components
63        // beyond the patch; an `-rc1` suffix rides on the patch itself.
64        let v = parse_dotted_version("git version 2.54.0.windows.1").unwrap();
65        assert_eq!((v.major, v.minor, v.patch), (2, 54, 0));
66        let v = parse_dotted_version("git version 2.41.0-rc1").unwrap();
67        assert_eq!((v.major, v.minor, v.patch), (2, 41, 0));
68        let v = parse_dotted_version("git version 2.54").unwrap();
69        assert_eq!(v.patch, 0, "missing patch defaults to 0");
70        // jj's banner is `jj 0.42.0`.
71        let v = parse_dotted_version("jj 0.42.0").unwrap();
72        assert_eq!((v.major, v.minor, v.patch), (0, 42, 0));
73        assert!(parse_dotted_version("no digits here").is_none());
74        assert!(parse_dotted_version("git version unknowable").is_none());
75    }
76
77    #[test]
78    fn orders_numerically() {
79        let lo = parse_dotted_version("jj 0.38.0").unwrap();
80        let hi = parse_dotted_version("jj 0.40.0").unwrap();
81        assert!(hi > lo);
82        assert!(
83            Version {
84                major: 2,
85                minor: 9,
86                patch: 0
87            } < Version {
88                major: 2,
89                minor: 10,
90                patch: 0
91            }
92        );
93    }
94
95    #[test]
96    fn displays_dotted() {
97        let v = parse_dotted_version("git version 2.54.1").unwrap();
98        assert_eq!(v.to_string(), "2.54.1");
99    }
100}
101
102// `parse_dotted_version` is a pure parser over an arbitrary `<tool> --version`
103// banner (a binary on the user's machine), with byte-offset slicing in
104// `leading_number` — so the load-bearing invariant is "never panic, whatever the
105// bytes". Lock it against future edits.
106#[cfg(test)]
107mod proptests {
108    use super::*;
109    use proptest::prelude::*;
110
111    proptest! {
112        #[test]
113        fn never_panics_on_arbitrary_text(s in any::<String>()) {
114            let _ = parse_dotted_version(&s);
115        }
116
117        // …and on version-ish input that reaches the digit-run slicing branches.
118        #[test]
119        fn never_panics_on_versionish_text(s in r"[a-z]{0,6} ?[0-9.\-+a-z]{0,20}") {
120            let _ = parse_dotted_version(&s);
121        }
122    }
123}