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.contains('+') {
40                return Err(PrimitiveError::Invalid {
41                    message: "build metadata must not contain '+'",
42                });
43            }
44            validate_identifier_set(&b, IdentifierKind::Build)?;
45            (&s[..idx], Some(b))
46        } else {
47            (s, None)
48        };
49
50        let (s, pre) = if let Some(idx) = s.find('-') {
51            let p = s[idx + 1..].to_string();
52            validate_identifier_set(&p, IdentifierKind::PreRelease)?;
53            (&s[..idx], Some(p))
54        } else {
55            (s, None)
56        };
57
58        let mut parts = s.splitn(4, '.');
59        let major = parse_version_component(parts.next().unwrap_or(""))?;
60        let minor = parse_version_component(parts.next().unwrap_or(""))?;
61        let patch = parse_version_component(parts.next().unwrap_or(""))?;
62
63        if parts.next().is_some() {
64            return Err(PrimitiveError::Invalid {
65                message: "semver must have exactly three dot-separated components",
66            });
67        }
68
69        Ok(Self {
70            major,
71            minor,
72            patch,
73            pre,
74            build,
75        })
76    }
77
78    pub fn major(&self) -> u64 {
79        self.major
80    }
81    pub fn minor(&self) -> u64 {
82        self.minor
83    }
84    pub fn patch(&self) -> u64 {
85        self.patch
86    }
87
88    /// Returns the pre-release identifier if present.
89    pub fn pre(&self) -> Option<&str> {
90        self.pre.as_deref()
91    }
92
93    /// Returns the build metadata if present.
94    pub fn build(&self) -> Option<&str> {
95        self.build.as_deref()
96    }
97
98    /// Returns `true` if this is a pre-release version.
99    pub fn is_pre_release(&self) -> bool {
100        self.pre.is_some()
101    }
102}
103
104fn parse_version_component(s: &str) -> PrimitiveResult<u64> {
105    if s.is_empty() {
106        return Err(PrimitiveError::Invalid {
107            message: "semver component must not be empty",
108        });
109    }
110    if s.len() > 1 && s.starts_with('0') {
111        return Err(PrimitiveError::Invalid {
112            message: "semver component must not have leading zeros",
113        });
114    }
115    parse_u64(s).ok_or(PrimitiveError::Invalid {
116        message: "semver component must be a non-negative integer",
117    })
118}
119
120fn parse_u64(s: &str) -> Option<u64> {
121    if s.is_empty() {
122        return None;
123    }
124    let mut result: u64 = 0;
125    for b in s.bytes() {
126        if !b.is_ascii_digit() {
127            return None;
128        }
129        let digit = (b - b'0') as u64;
130        result = result.checked_mul(10)?.checked_add(digit)?;
131    }
132    Some(result)
133}
134
135#[derive(Copy, Clone)]
136enum IdentifierKind {
137    PreRelease,
138    Build,
139}
140
141fn validate_identifier_set(s: &str, kind: IdentifierKind) -> PrimitiveResult<()> {
142    if s.is_empty() {
143        return Err(PrimitiveError::Invalid {
144            message: match kind {
145                IdentifierKind::PreRelease => "pre-release identifier must not be empty after '-'",
146                IdentifierKind::Build => "build metadata must not be empty after '+'",
147            },
148        });
149    }
150
151    for identifier in s.split('.') {
152        if identifier.is_empty() {
153            return Err(PrimitiveError::Invalid {
154                message: "semver identifiers must not be empty",
155            });
156        }
157
158        if !identifier
159            .bytes()
160            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
161        {
162            return Err(PrimitiveError::Invalid {
163                message: "semver identifiers must contain only ASCII alphanumerics and hyphens",
164            });
165        }
166
167        if matches!(kind, IdentifierKind::PreRelease)
168            && is_numeric_identifier(identifier)
169            && identifier.len() > 1
170            && identifier.starts_with('0')
171        {
172            return Err(PrimitiveError::Invalid {
173                message: "numeric pre-release identifiers must not have leading zeros",
174            });
175        }
176    }
177
178    Ok(())
179}
180
181fn is_numeric_identifier(s: &str) -> bool {
182    s.bytes().all(|b| b.is_ascii_digit())
183}
184
185fn compare_numeric_identifier(a: &str, b: &str) -> core::cmp::Ordering {
186    a.len().cmp(&b.len()).then_with(|| a.cmp(b))
187}
188
189fn compare_pre_release(a: &str, b: &str) -> core::cmp::Ordering {
190    for (left, right) in a.split('.').zip(b.split('.')) {
191        let left_numeric = is_numeric_identifier(left);
192        let right_numeric = is_numeric_identifier(right);
193
194        let ordering = match (left_numeric, right_numeric) {
195            (true, true) => compare_numeric_identifier(left, right),
196            (true, false) => core::cmp::Ordering::Less,
197            (false, true) => core::cmp::Ordering::Greater,
198            (false, false) => left.cmp(right),
199        };
200
201        if ordering != core::cmp::Ordering::Equal {
202            return ordering;
203        }
204    }
205
206    a.split('.').count().cmp(&b.split('.').count())
207}
208
209impl fmt::Display for SemVer {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
212        if let Some(pre) = &self.pre {
213            write!(f, "-{pre}")?;
214        }
215        if let Some(build) = &self.build {
216            write!(f, "+{build}")?;
217        }
218        Ok(())
219    }
220}
221
222impl PartialOrd for SemVer {
223    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
224        Some(self.cmp(other))
225    }
226}
227
228impl Ord for SemVer {
229    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
230        let v = (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
231        if v != core::cmp::Ordering::Equal {
232            return v;
233        }
234        // Per semver spec ยง11: pre-release < release when core version is equal.
235        match (&self.pre, &other.pre) {
236            (None, None) => core::cmp::Ordering::Equal,
237            (Some(_), None) => core::cmp::Ordering::Less,
238            (None, Some(_)) => core::cmp::Ordering::Greater,
239            (Some(a), Some(b)) => compare_pre_release(a, b),
240        }
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::SemVer;
247    use crate::PrimitiveError;
248    use alloc::string::ToString;
249
250    #[test]
251    fn parses_simple() {
252        let v = SemVer::parse("1.2.3").unwrap();
253        assert_eq!(v.major(), 1);
254        assert_eq!(v.minor(), 2);
255        assert_eq!(v.patch(), 3);
256        assert!(v.pre().is_none());
257        assert!(v.build().is_none());
258    }
259
260    #[test]
261    fn parses_with_pre_release() {
262        let v = SemVer::parse("2.0.0-beta.1").unwrap();
263        assert_eq!(v.pre(), Some("beta.1"));
264        assert!(v.is_pre_release());
265    }
266
267    #[test]
268    fn parses_with_build() {
269        let v = SemVer::parse("1.0.0+build.456").unwrap();
270        assert_eq!(v.build(), Some("build.456"));
271    }
272
273    #[test]
274    fn parses_pre_and_build() {
275        let v = SemVer::parse("1.0.0-alpha.1+build.001").unwrap();
276        assert_eq!(v.pre(), Some("alpha.1"));
277        assert_eq!(v.build(), Some("build.001"));
278    }
279
280    #[test]
281    fn rejects_empty() {
282        assert_eq!(SemVer::parse("").unwrap_err(), PrimitiveError::Empty);
283    }
284
285    #[test]
286    fn rejects_missing_components() {
287        assert!(SemVer::parse("1.2").is_err());
288    }
289
290    #[test]
291    fn rejects_too_many_components() {
292        assert!(SemVer::parse("1.2.3.4").is_err());
293    }
294
295    #[test]
296    fn rejects_leading_zeros() {
297        assert!(SemVer::parse("1.02.3").is_err());
298    }
299
300    #[test]
301    fn rejects_non_numeric() {
302        assert!(SemVer::parse("a.b.c").is_err());
303    }
304
305    #[test]
306    fn rejects_empty_pre_release() {
307        assert!(SemVer::parse("1.0.0-").is_err());
308    }
309
310    #[test]
311    fn rejects_empty_build() {
312        assert!(SemVer::parse("1.0.0+").is_err());
313    }
314
315    #[test]
316    fn rejects_build_with_plus() {
317        assert!(SemVer::parse("1.0.0+a+b").is_err());
318    }
319
320    #[test]
321    fn rejects_invalid_pre_release_identifiers() {
322        assert!(SemVer::parse("1.0.0-alpha..1").is_err());
323        assert!(SemVer::parse("1.0.0-alpha_1").is_err());
324        assert!(SemVer::parse("1.0.0-01").is_err());
325    }
326
327    #[test]
328    fn rejects_invalid_build_identifiers() {
329        assert!(SemVer::parse("1.0.0+build..1").is_err());
330        assert!(SemVer::parse("1.0.0+build_1").is_err());
331    }
332
333    #[test]
334    fn display() {
335        assert_eq!(SemVer::parse("1.2.3").unwrap().to_string(), "1.2.3");
336        assert_eq!(
337            SemVer::parse("2.0.0-beta.1").unwrap().to_string(),
338            "2.0.0-beta.1"
339        );
340        assert_eq!(
341            SemVer::parse("1.0.0+build").unwrap().to_string(),
342            "1.0.0+build"
343        );
344        assert_eq!(
345            SemVer::parse("1.0.0-alpha+build").unwrap().to_string(),
346            "1.0.0-alpha+build"
347        );
348    }
349
350    #[test]
351    fn new_constructor() {
352        let v = SemVer::new(1, 0, 0);
353        assert_eq!(v.to_string(), "1.0.0");
354    }
355
356    #[test]
357    fn ordering() {
358        let v1 = SemVer::parse("1.0.0").unwrap();
359        let v2 = SemVer::parse("2.0.0").unwrap();
360        let v3 = SemVer::parse("1.1.0").unwrap();
361        assert!(v1 < v2);
362        assert!(v1 < v3);
363        assert!(v3 < v2);
364    }
365
366    #[test]
367    fn pre_release_sorts_below_release() {
368        let release = SemVer::parse("1.0.0").unwrap();
369        let pre = SemVer::parse("1.0.0-alpha").unwrap();
370        assert!(pre < release);
371        assert!(release > pre);
372    }
373
374    #[test]
375    fn pre_release_compared_lexicographically() {
376        let alpha = SemVer::parse("1.0.0-alpha").unwrap();
377        let beta = SemVer::parse("1.0.0-beta").unwrap();
378        assert!(alpha < beta);
379    }
380
381    #[test]
382    fn pre_release_numeric_identifiers_compare_numerically() {
383        let two = SemVer::parse("1.0.0-alpha.2").unwrap();
384        let ten = SemVer::parse("1.0.0-alpha.10").unwrap();
385        assert!(two < ten);
386    }
387
388    #[test]
389    fn pre_release_numeric_identifier_comparison_does_not_overflow() {
390        let smaller = SemVer::parse("1.0.0-alpha.999999999999999999999999999999").unwrap();
391        let larger = SemVer::parse("1.0.0-alpha.1000000000000000000000000000000").unwrap();
392        assert!(smaller < larger);
393    }
394
395    #[test]
396    fn pre_release_numeric_identifiers_sort_before_non_numeric() {
397        let numeric = SemVer::parse("1.0.0-1").unwrap();
398        let alpha = SemVer::parse("1.0.0-alpha").unwrap();
399        assert!(numeric < alpha);
400    }
401}