Skip to main content

reliakit_primitives/
semver.rs

1use crate::{PrimitiveError, PrimitiveResult};
2use alloc::string::{String, ToString};
3use core::{fmt, str::FromStr};
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 const 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    /// Returns the major version component.
79    pub fn major(&self) -> u64 {
80        self.major
81    }
82    /// Returns the minor version component.
83    pub fn minor(&self) -> u64 {
84        self.minor
85    }
86    /// Returns the patch version component.
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    /// Compares semantic version precedence according to the SemVer rules.
107    ///
108    /// Build metadata is ignored for SemVer precedence. This differs from
109    /// [`Ord`], which uses build metadata as a final tie-breaker so that Rust's
110    /// total ordering remains consistent with [`Eq`].
111    pub fn cmp_precedence(&self, other: &Self) -> core::cmp::Ordering {
112        let core =
113            (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
114        if core != core::cmp::Ordering::Equal {
115            return core;
116        }
117
118        // Per SemVer spec ยง11: pre-release < release when core version is equal.
119        match (&self.pre, &other.pre) {
120            (None, None) => core::cmp::Ordering::Equal,
121            (Some(_), None) => core::cmp::Ordering::Less,
122            (None, Some(_)) => core::cmp::Ordering::Greater,
123            (Some(a), Some(b)) => compare_pre_release(a, b),
124        }
125    }
126}
127
128fn parse_version_component(s: &str) -> PrimitiveResult<u64> {
129    if s.is_empty() {
130        return Err(PrimitiveError::Invalid {
131            message: "semver component must not be empty",
132        });
133    }
134    if s.len() > 1 && s.starts_with('0') {
135        return Err(PrimitiveError::Invalid {
136            message: "semver component must not have leading zeros",
137        });
138    }
139    parse_u64(s).ok_or(PrimitiveError::Invalid {
140        message: "semver component must be a non-negative integer",
141    })
142}
143
144fn parse_u64(s: &str) -> Option<u64> {
145    if s.is_empty() {
146        return None;
147    }
148    let mut result: u64 = 0;
149    for b in s.bytes() {
150        if !b.is_ascii_digit() {
151            return None;
152        }
153        let digit = (b - b'0') as u64;
154        result = result.checked_mul(10)?.checked_add(digit)?;
155    }
156    Some(result)
157}
158
159#[derive(Copy, Clone)]
160enum IdentifierKind {
161    PreRelease,
162    Build,
163}
164
165fn validate_identifier_set(s: &str, kind: IdentifierKind) -> PrimitiveResult<()> {
166    if s.is_empty() {
167        return Err(PrimitiveError::Invalid {
168            message: match kind {
169                IdentifierKind::PreRelease => "pre-release identifier must not be empty after '-'",
170                IdentifierKind::Build => "build metadata must not be empty after '+'",
171            },
172        });
173    }
174
175    for identifier in s.split('.') {
176        if identifier.is_empty() {
177            return Err(PrimitiveError::Invalid {
178                message: "semver identifiers must not be empty",
179            });
180        }
181
182        if !identifier
183            .bytes()
184            .all(|b| b.is_ascii_alphanumeric() || b == b'-')
185        {
186            return Err(PrimitiveError::Invalid {
187                message: "semver identifiers must contain only ASCII alphanumerics and hyphens",
188            });
189        }
190
191        if matches!(kind, IdentifierKind::PreRelease)
192            && is_numeric_identifier(identifier)
193            && identifier.len() > 1
194            && identifier.starts_with('0')
195        {
196            return Err(PrimitiveError::Invalid {
197                message: "numeric pre-release identifiers must not have leading zeros",
198            });
199        }
200    }
201
202    Ok(())
203}
204
205fn is_numeric_identifier(s: &str) -> bool {
206    s.bytes().all(|b| b.is_ascii_digit())
207}
208
209fn compare_numeric_identifier(a: &str, b: &str) -> core::cmp::Ordering {
210    a.len().cmp(&b.len()).then_with(|| a.cmp(b))
211}
212
213fn compare_pre_release(a: &str, b: &str) -> core::cmp::Ordering {
214    for (left, right) in a.split('.').zip(b.split('.')) {
215        let left_numeric = is_numeric_identifier(left);
216        let right_numeric = is_numeric_identifier(right);
217
218        let ordering = match (left_numeric, right_numeric) {
219            (true, true) => compare_numeric_identifier(left, right),
220            (true, false) => core::cmp::Ordering::Less,
221            (false, true) => core::cmp::Ordering::Greater,
222            (false, false) => left.cmp(right),
223        };
224
225        if ordering != core::cmp::Ordering::Equal {
226            return ordering;
227        }
228    }
229
230    a.split('.').count().cmp(&b.split('.').count())
231}
232
233impl fmt::Display for SemVer {
234    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
235        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)?;
236        if let Some(pre) = &self.pre {
237            write!(f, "-{pre}")?;
238        }
239        if let Some(build) = &self.build {
240            write!(f, "+{build}")?;
241        }
242        Ok(())
243    }
244}
245
246impl FromStr for SemVer {
247    type Err = PrimitiveError;
248
249    fn from_str(s: &str) -> Result<Self, Self::Err> {
250        Self::parse(s)
251    }
252}
253
254impl PartialEq<str> for SemVer {
255    fn eq(&self, other: &str) -> bool {
256        Self::parse(other).is_ok_and(|other| self == &other)
257    }
258}
259
260impl PartialEq<&str> for SemVer {
261    fn eq(&self, other: &&str) -> bool {
262        self.eq(*other)
263    }
264}
265
266impl PartialEq<String> for SemVer {
267    fn eq(&self, other: &String) -> bool {
268        self.eq(other.as_str())
269    }
270}
271
272impl PartialEq<&String> for SemVer {
273    fn eq(&self, other: &&String) -> bool {
274        self.eq(other.as_str())
275    }
276}
277
278impl PartialOrd for SemVer {
279    fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
280        Some(self.cmp(other))
281    }
282}
283
284impl Ord for SemVer {
285    fn cmp(&self, other: &Self) -> core::cmp::Ordering {
286        self.cmp_precedence(other)
287            .then_with(|| self.build.cmp(&other.build))
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::SemVer;
294    use crate::PrimitiveError;
295    use alloc::collections::BTreeSet;
296    use alloc::string::ToString;
297
298    #[test]
299    fn parses_simple() {
300        let v = SemVer::parse("1.2.3").unwrap();
301        assert_eq!(v.major(), 1);
302        assert_eq!(v.minor(), 2);
303        assert_eq!(v.patch(), 3);
304        assert!(v.pre().is_none());
305        assert!(v.build().is_none());
306    }
307
308    #[test]
309    fn parses_with_pre_release() {
310        let v = SemVer::parse("2.0.0-beta.1").unwrap();
311        assert_eq!(v.pre(), Some("beta.1"));
312        assert!(v.is_pre_release());
313    }
314
315    #[test]
316    fn parses_with_build() {
317        let v = SemVer::parse("1.0.0+build.456").unwrap();
318        assert_eq!(v.build(), Some("build.456"));
319    }
320
321    #[test]
322    fn parses_pre_and_build() {
323        let v = SemVer::parse("1.0.0-alpha.1+build.001").unwrap();
324        assert_eq!(v.pre(), Some("alpha.1"));
325        assert_eq!(v.build(), Some("build.001"));
326    }
327
328    #[test]
329    fn rejects_empty() {
330        assert_eq!(SemVer::parse("").unwrap_err(), PrimitiveError::Empty);
331    }
332
333    #[test]
334    fn rejects_missing_components() {
335        assert!(SemVer::parse("1.2").is_err());
336    }
337
338    #[test]
339    fn rejects_too_many_components() {
340        assert!(SemVer::parse("1.2.3.4").is_err());
341    }
342
343    #[test]
344    fn rejects_leading_zeros() {
345        assert!(SemVer::parse("1.02.3").is_err());
346    }
347
348    #[test]
349    fn rejects_non_numeric() {
350        assert!(SemVer::parse("a.b.c").is_err());
351    }
352
353    #[test]
354    fn rejects_empty_pre_release() {
355        assert!(SemVer::parse("1.0.0-").is_err());
356    }
357
358    #[test]
359    fn rejects_empty_build() {
360        assert!(SemVer::parse("1.0.0+").is_err());
361    }
362
363    #[test]
364    fn rejects_build_with_plus() {
365        assert!(SemVer::parse("1.0.0+a+b").is_err());
366    }
367
368    #[test]
369    fn rejects_invalid_pre_release_identifiers() {
370        assert!(SemVer::parse("1.0.0-alpha..1").is_err());
371        assert!(SemVer::parse("1.0.0-alpha_1").is_err());
372        assert!(SemVer::parse("1.0.0-01").is_err());
373    }
374
375    #[test]
376    fn rejects_invalid_build_identifiers() {
377        assert!(SemVer::parse("1.0.0+build..1").is_err());
378        assert!(SemVer::parse("1.0.0+build_1").is_err());
379    }
380
381    #[test]
382    fn display() {
383        assert_eq!(SemVer::parse("1.2.3").unwrap().to_string(), "1.2.3");
384        assert_eq!(
385            SemVer::parse("2.0.0-beta.1").unwrap().to_string(),
386            "2.0.0-beta.1"
387        );
388        assert_eq!(
389            SemVer::parse("1.0.0+build").unwrap().to_string(),
390            "1.0.0+build"
391        );
392        assert_eq!(
393            SemVer::parse("1.0.0-alpha+build").unwrap().to_string(),
394            "1.0.0-alpha+build"
395        );
396    }
397
398    #[test]
399    fn new_constructor() {
400        let v = SemVer::new(1, 0, 0);
401        assert_eq!(v.to_string(), "1.0.0");
402    }
403
404    #[test]
405    fn ordering() {
406        let v1 = SemVer::parse("1.0.0").unwrap();
407        let v2 = SemVer::parse("2.0.0").unwrap();
408        let v3 = SemVer::parse("1.1.0").unwrap();
409        assert!(v1 < v2);
410        assert!(v1 < v3);
411        assert!(v3 < v2);
412    }
413
414    #[test]
415    fn pre_release_sorts_below_release() {
416        let release = SemVer::parse("1.0.0").unwrap();
417        let pre = SemVer::parse("1.0.0-alpha").unwrap();
418        assert!(pre < release);
419        assert!(release > pre);
420    }
421
422    #[test]
423    fn pre_release_compared_lexicographically() {
424        let alpha = SemVer::parse("1.0.0-alpha").unwrap();
425        let beta = SemVer::parse("1.0.0-beta").unwrap();
426        assert!(alpha < beta);
427    }
428
429    #[test]
430    fn pre_release_numeric_identifiers_compare_numerically() {
431        let two = SemVer::parse("1.0.0-alpha.2").unwrap();
432        let ten = SemVer::parse("1.0.0-alpha.10").unwrap();
433        assert!(two < ten);
434    }
435
436    #[test]
437    fn pre_release_numeric_identifier_comparison_does_not_overflow() {
438        let smaller = SemVer::parse("1.0.0-alpha.999999999999999999999999999999").unwrap();
439        let larger = SemVer::parse("1.0.0-alpha.1000000000000000000000000000000").unwrap();
440        assert!(smaller < larger);
441    }
442
443    #[test]
444    fn pre_release_numeric_identifiers_sort_before_non_numeric() {
445        let numeric = SemVer::parse("1.0.0-1").unwrap();
446        let alpha = SemVer::parse("1.0.0-alpha").unwrap();
447        assert!(numeric < alpha);
448    }
449
450    #[test]
451    fn precedence_ignores_build_metadata() {
452        let first = SemVer::parse("1.0.0+build.1").unwrap();
453        let second = SemVer::parse("1.0.0+build.2").unwrap();
454
455        assert_eq!(first.cmp_precedence(&second), core::cmp::Ordering::Equal);
456    }
457
458    #[test]
459    fn ord_is_consistent_with_eq_for_build_metadata() {
460        let first = SemVer::parse("1.0.0+build.1").unwrap();
461        let second = SemVer::parse("1.0.0+build.2").unwrap();
462
463        assert_ne!(first, second);
464        assert_ne!(first.cmp(&second), core::cmp::Ordering::Equal);
465    }
466
467    #[test]
468    fn btree_set_keeps_distinct_build_metadata() {
469        let mut versions = BTreeSet::new();
470
471        versions.insert(SemVer::parse("1.0.0+build.1").unwrap());
472        versions.insert(SemVer::parse("1.0.0+build.2").unwrap());
473
474        assert_eq!(versions.len(), 2);
475    }
476
477    #[test]
478    fn from_str_and_string_comparisons() {
479        let version = "1.2.3-beta.1".parse::<SemVer>().unwrap();
480        let owned = "1.2.3-beta.1".to_string();
481        assert_eq!(version, "1.2.3-beta.1");
482        assert_eq!(version, owned);
483        assert!("1.2".parse::<SemVer>().is_err());
484    }
485}