Skip to main content

reliakit_primitives/
semver.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::{String, ToString};
3use core::fmt;
4
5/// Semantic version in the form `MAJOR.MINOR.PATCH` with optional pre-release
6/// and build metadata identifiers.
7///
8/// Parses `1.2.3`, `1.2.3-beta.1`, `1.2.3+build.456`, and
9/// `1.2.3-alpha.1+build.456`.
10#[derive(Debug, Clone, PartialEq, Eq, Hash)]
11pub struct SemVer {
12    major: u64,
13    minor: u64,
14    patch: u64,
15    pre: Option<String>,
16    build: Option<String>,
17}
18
19impl SemVer {
20    /// Creates a `SemVer` with no pre-release or build metadata.
21    pub fn new(major: u64, minor: u64, patch: u64) -> Self {
22        Self {
23            major,
24            minor,
25            patch,
26            pre: None,
27            build: None,
28        }
29    }
30
31    /// Parses a semver string.
32    pub fn parse(s: &str) -> PrimitiveResult<Self> {
33        if s.is_empty() {
34            return Err(PrimitiveError::Empty);
35        }
36
37        let (s, build) = if let Some(idx) = s.find('+') {
38            let b = s[idx + 1..].to_string();
39            if b.is_empty() {
40                return Err(PrimitiveError::Invalid {
41                    message: "build metadata must not be empty after '+'",
42                });
43            }
44            (&s[..idx], Some(b))
45        } else {
46            (s, None)
47        };
48
49        let (s, pre) = if let Some(idx) = s.find('-') {
50            let p = s[idx + 1..].to_string();
51            if p.is_empty() {
52                return Err(PrimitiveError::Invalid {
53                    message: "pre-release identifier must not be empty after '-'",
54                });
55            }
56            (&s[..idx], Some(p))
57        } else {
58            (s, None)
59        };
60
61        let mut parts = s.splitn(4, '.');
62        let major = parse_version_component(parts.next().unwrap_or(""))?;
63        let minor = parse_version_component(parts.next().unwrap_or(""))?;
64        let patch = parse_version_component(parts.next().unwrap_or(""))?;
65
66        if parts.next().is_some() {
67            return Err(PrimitiveError::Invalid {
68                message: "semver must have exactly three dot-separated components",
69            });
70        }
71
72        Ok(Self {
73            major,
74            minor,
75            patch,
76            pre,
77            build,
78        })
79    }
80
81    pub fn major(&self) -> u64 {
82        self.major
83    }
84    pub fn minor(&self) -> u64 {
85        self.minor
86    }
87    pub fn patch(&self) -> u64 {
88        self.patch
89    }
90
91    /// Returns the pre-release identifier if present.
92    pub fn pre(&self) -> Option<&str> {
93        self.pre.as_deref()
94    }
95
96    /// Returns the build metadata if present.
97    pub fn build(&self) -> Option<&str> {
98        self.build.as_deref()
99    }
100
101    /// Returns `true` if this is a pre-release version.
102    pub fn is_pre_release(&self) -> bool {
103        self.pre.is_some()
104    }
105}
106
107fn parse_version_component(s: &str) -> PrimitiveResult<u64> {
108    if s.is_empty() {
109        return Err(PrimitiveError::Invalid {
110            message: "semver component must not be empty",
111        });
112    }
113    if s.len() > 1 && s.starts_with('0') {
114        return Err(PrimitiveError::Invalid {
115            message: "semver component must not have leading zeros",
116        });
117    }
118    parse_u64(s).ok_or(PrimitiveError::Invalid {
119        message: "semver component must be a non-negative integer",
120    })
121}
122
123fn parse_u64(s: &str) -> Option<u64> {
124    if s.is_empty() {
125        return None;
126    }
127    let mut result: u64 = 0;
128    for c in s.chars() {
129        let digit = c.to_digit(10)? as u64;
130        result = result.checked_mul(10)?.checked_add(digit)?;
131    }
132    Some(result)
133}
134
135impl fmt::Display for SemVer {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
138        if let Some(pre) = &self.pre {
139            write!(f, "-{pre}")?;
140        }
141        if let Some(build) = &self.build {
142            write!(f, "+{build}")?;
143        }
144        Ok(())
145    }
146}
147
148impl PartialOrd for SemVer {
149    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
150        Some(self.cmp(other))
151    }
152}
153
154impl Ord for SemVer {
155    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
156        let v = (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
157        if v != core::cmp::Ordering::Equal {
158            return v;
159        }
160        // Per semver spec ยง11: pre-release < release when core version is equal.
161        match (&self.pre, &other.pre) {
162            (None, None) => core::cmp::Ordering::Equal,
163            (Some(_), None) => core::cmp::Ordering::Less,
164            (None, Some(_)) => core::cmp::Ordering::Greater,
165            (Some(a), Some(b)) => a.cmp(b),
166        }
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use super::SemVer;
173    use crate::PrimitiveError;
174    use alloc::string::ToString;
175
176    #[test]
177    fn parses_simple() {
178        let v = SemVer::parse("1.2.3").unwrap();
179        assert_eq!(v.major(), 1);
180        assert_eq!(v.minor(), 2);
181        assert_eq!(v.patch(), 3);
182        assert!(v.pre().is_none());
183        assert!(v.build().is_none());
184    }
185
186    #[test]
187    fn parses_with_pre_release() {
188        let v = SemVer::parse("2.0.0-beta.1").unwrap();
189        assert_eq!(v.pre(), Some("beta.1"));
190        assert!(v.is_pre_release());
191    }
192
193    #[test]
194    fn parses_with_build() {
195        let v = SemVer::parse("1.0.0+build.456").unwrap();
196        assert_eq!(v.build(), Some("build.456"));
197    }
198
199    #[test]
200    fn parses_pre_and_build() {
201        let v = SemVer::parse("1.0.0-alpha.1+build.001").unwrap();
202        assert_eq!(v.pre(), Some("alpha.1"));
203        assert_eq!(v.build(), Some("build.001"));
204    }
205
206    #[test]
207    fn rejects_empty() {
208        assert_eq!(SemVer::parse("").unwrap_err(), PrimitiveError::Empty);
209    }
210
211    #[test]
212    fn rejects_missing_components() {
213        assert!(SemVer::parse("1.2").is_err());
214    }
215
216    #[test]
217    fn rejects_too_many_components() {
218        assert!(SemVer::parse("1.2.3.4").is_err());
219    }
220
221    #[test]
222    fn rejects_leading_zeros() {
223        assert!(SemVer::parse("1.02.3").is_err());
224    }
225
226    #[test]
227    fn rejects_non_numeric() {
228        assert!(SemVer::parse("a.b.c").is_err());
229    }
230
231    #[test]
232    fn rejects_empty_pre_release() {
233        assert!(SemVer::parse("1.0.0-").is_err());
234    }
235
236    #[test]
237    fn rejects_empty_build() {
238        assert!(SemVer::parse("1.0.0+").is_err());
239    }
240
241    #[test]
242    fn display() {
243        assert_eq!(SemVer::parse("1.2.3").unwrap().to_string(), "1.2.3");
244        assert_eq!(
245            SemVer::parse("2.0.0-beta.1").unwrap().to_string(),
246            "2.0.0-beta.1"
247        );
248        assert_eq!(
249            SemVer::parse("1.0.0+build").unwrap().to_string(),
250            "1.0.0+build"
251        );
252        assert_eq!(
253            SemVer::parse("1.0.0-alpha+build").unwrap().to_string(),
254            "1.0.0-alpha+build"
255        );
256    }
257
258    #[test]
259    fn new_constructor() {
260        let v = SemVer::new(1, 0, 0);
261        assert_eq!(v.to_string(), "1.0.0");
262    }
263
264    #[test]
265    fn ordering() {
266        let v1 = SemVer::parse("1.0.0").unwrap();
267        let v2 = SemVer::parse("2.0.0").unwrap();
268        let v3 = SemVer::parse("1.1.0").unwrap();
269        assert!(v1 < v2);
270        assert!(v1 < v3);
271        assert!(v3 < v2);
272    }
273
274    #[test]
275    fn pre_release_sorts_below_release() {
276        let release = SemVer::parse("1.0.0").unwrap();
277        let pre = SemVer::parse("1.0.0-alpha").unwrap();
278        assert!(pre < release);
279        assert!(release > pre);
280    }
281
282    #[test]
283    fn pre_release_compared_lexicographically() {
284        let alpha = SemVer::parse("1.0.0-alpha").unwrap();
285        let beta = SemVer::parse("1.0.0-beta").unwrap();
286        assert!(alpha < beta);
287    }
288}