Skip to main content

gen_types/
version.rs

1//! Typed [`Version`] — SemVer-compatible. Adapters convert their
2//! native version type (cargo's [`semver::Version`], npm's loose
3//! pre-release variants, RubyGems' four-segment versions, PEP-440,
4//! …) into this canonical shape during parse.
5
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::fmt;
9
10/// SemVer 2.0.0 compatible version with optional pre-release + build
11/// metadata. Major / minor / patch are required; everything past is
12/// optional + language-specific extension hooks live in `extension`.
13#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Version {
15    pub major: u64,
16    pub minor: u64,
17    pub patch: u64,
18    /// `1.2.3-rc.1` → `Some("rc.1")`. Empty string is not legal —
19    /// use `None` to indicate no pre-release.
20    #[serde(default)]
21    pub pre: Option<String>,
22    /// `1.2.3+build.42` → `Some("build.42")`. Build metadata is
23    /// version-comparison-irrelevant per SemVer (used only for
24    /// display).
25    #[serde(default)]
26    pub build: Option<String>,
27    /// Language-specific extension (e.g. RubyGems has a 4th segment;
28    /// Python's PEP-440 has epoch + post + dev). Adapter-owned —
29    /// the engine doesn't interpret this for comparison purposes.
30    #[serde(default)]
31    pub extension: Option<String>,
32}
33
34impl Version {
35    /// Canonical constructor for `MAJOR.MINOR.PATCH` (no pre / build).
36    #[must_use]
37    pub const fn new(major: u64, minor: u64, patch: u64) -> Self {
38        Self {
39            major,
40            minor,
41            patch,
42            pre: None,
43            build: None,
44            extension: None,
45        }
46    }
47
48    /// Parse a SemVer-shaped string. Returns `None` on malformed
49    /// input — adapters that need a typed error should validate at
50    /// their boundary.
51    #[must_use]
52    pub fn parse(s: &str) -> Option<Self> {
53        // Split off build metadata first (everything after first '+').
54        let (without_build, build) = match s.split_once('+') {
55            Some((a, b)) => (a, Some(b.to_string())),
56            None => (s, None),
57        };
58        // Then pre-release (everything after first '-').
59        let (without_pre, pre) = match without_build.split_once('-') {
60            Some((a, b)) => (a, Some(b.to_string())),
61            None => (without_build, None),
62        };
63        let mut parts = without_pre.split('.');
64        let major = parts.next()?.parse().ok()?;
65        let minor = parts.next()?.parse().ok()?;
66        let patch = parts.next()?.parse().ok()?;
67        // Any extra segments fold into extension (e.g. RubyGems 4th).
68        let rest: Vec<&str> = parts.collect();
69        let extension = if rest.is_empty() {
70            None
71        } else {
72            Some(rest.join("."))
73        };
74        Some(Self {
75            major,
76            minor,
77            patch,
78            pre,
79            build,
80            extension,
81        })
82    }
83}
84
85impl PartialOrd for Version {
86    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
87        Some(self.cmp(other))
88    }
89}
90
91impl Ord for Version {
92    fn cmp(&self, other: &Self) -> Ordering {
93        // SemVer precedence — major, minor, patch, then pre-release
94        // (no pre-release sorts AFTER any pre-release of the same
95        // major.minor.patch). Build metadata is comparison-irrelevant.
96        match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
97            Ordering::Equal => match (&self.pre, &other.pre) {
98                (None, None) => Ordering::Equal,
99                (None, Some(_)) => Ordering::Greater,
100                (Some(_), None) => Ordering::Less,
101                (Some(a), Some(b)) => a.cmp(b),
102            },
103            other => other,
104        }
105    }
106}
107
108impl fmt::Display for Version {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
111        if let Some(pre) = &self.pre {
112            write!(f, "-{pre}")?;
113        }
114        if let Some(build) = &self.build {
115            write!(f, "+{build}")?;
116        }
117        if let Some(ext) = &self.extension {
118            write!(f, ".{ext}")?;
119        }
120        Ok(())
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parse_basic_semver() {
130        let v = Version::parse("1.2.3").unwrap();
131        assert_eq!(v, Version::new(1, 2, 3));
132    }
133
134    #[test]
135    fn parse_pre_release() {
136        let v = Version::parse("1.2.3-rc.1").unwrap();
137        assert_eq!(v.pre.as_deref(), Some("rc.1"));
138    }
139
140    #[test]
141    fn parse_build_metadata() {
142        let v = Version::parse("1.2.3+build.42").unwrap();
143        assert_eq!(v.build.as_deref(), Some("build.42"));
144    }
145
146    #[test]
147    fn parse_pre_and_build() {
148        let v = Version::parse("1.2.3-rc.1+build.42").unwrap();
149        assert_eq!(v.pre.as_deref(), Some("rc.1"));
150        assert_eq!(v.build.as_deref(), Some("build.42"));
151    }
152
153    #[test]
154    fn parse_rubygems_four_segment() {
155        // RubyGems: 1.2.3.beta1 → extension captures the 4th segment.
156        let v = Version::parse("1.2.3.beta1").unwrap();
157        assert_eq!(v.extension.as_deref(), Some("beta1"));
158    }
159
160    #[test]
161    fn parse_invalid_returns_none() {
162        assert!(Version::parse("not-a-version").is_none());
163        assert!(Version::parse("1").is_none());
164        assert!(Version::parse("1.2").is_none());
165    }
166
167    #[test]
168    fn ordering_basic() {
169        assert!(Version::new(1, 0, 0) < Version::new(1, 0, 1));
170        assert!(Version::new(1, 1, 0) > Version::new(1, 0, 99));
171        assert!(Version::new(2, 0, 0) > Version::new(1, 99, 99));
172    }
173
174    #[test]
175    fn ordering_pre_release_sorts_before_release() {
176        let pre = Version::parse("1.2.3-rc.1").unwrap();
177        let rel = Version::new(1, 2, 3);
178        assert!(pre < rel);
179    }
180
181    #[test]
182    fn display_round_trip() {
183        let v = Version::parse("1.2.3-rc.1+build.42").unwrap();
184        assert_eq!(format!("{v}"), "1.2.3-rc.1+build.42");
185    }
186
187    #[test]
188    fn serde_json_round_trip() {
189        let v = Version::parse("1.2.3-rc.1").unwrap();
190        let j = serde_json::to_string(&v).unwrap();
191        let parsed: Version = serde_json::from_str(&j).unwrap();
192        assert_eq!(v, parsed);
193    }
194}