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