Skip to main content

ledvar_core/
version.rs

1//! Protocol version handling (SPEC §10). Only the MAJOR is contract-significant.
2
3use crate::error::Error;
4
5/// Parse a `MAJOR.MINOR.PATCH` string into its three numeric components.
6///
7/// The form is exactly **three dot-separated integers**, each either `0` or a non-zero digit
8/// followed by digits — i.e. **no leading zeros**, no sign, no pre-release/build suffix (SPEC §9:
9/// `01.2.0`, `1.2.0-rc1`, `"0"`, `"0.1"`, `"1.2.3.4"` are all rejected). This matches the tightened
10/// JSON schema pattern `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`, so `validate` and the
11/// schema agree on what is well-formed.
12///
13/// ```
14/// assert_eq!(ledvar_core::parse_major("0.3.1").unwrap(), 0);
15/// assert!(ledvar_core::parse_major("0.3").is_err());   // not MAJOR.MINOR.PATCH
16/// assert!(ledvar_core::parse_major("01.2.0").is_err()); // leading zero
17/// ```
18pub fn parse(version: &str) -> Result<(u64, u64, u64), Error> {
19    let bad = || Error::BadVersion(version.to_string());
20    // Exactly three components.
21    let parts: [&str; 3] = version
22        .split('.')
23        .collect::<Vec<_>>()
24        .try_into()
25        .map_err(|_| bad())?;
26    let mut nums = [0u64; 3];
27    for (slot, p) in nums.iter_mut().zip(parts) {
28        // Each: all ASCII digits, non-empty, and no leading zero (a lone "0" is fine).
29        if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
30            return Err(bad());
31        }
32        if p.len() > 1 && p.starts_with('0') {
33            return Err(bad());
34        }
35        *slot = p.parse::<u64>().map_err(|_| bad())?;
36    }
37    Ok((nums[0], nums[1], nums[2]))
38}
39
40/// Parse just the MAJOR (only MAJOR is contract-significant from MAJOR ≥ 1). Thin wrapper over
41/// [`parse`]; the full grammar (including MINOR/PATCH and no-leading-zeros) is still enforced.
42pub fn parse_major(version: &str) -> Result<u64, Error> {
43    parse(version).map(|(major, _, _)| major)
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49
50    #[test]
51    fn parses_major_of_well_formed() {
52        assert_eq!(parse_major("0.1.0").unwrap(), 0);
53        assert_eq!(parse_major("12.4.7").unwrap(), 12);
54    }
55
56    #[test]
57    fn rejects_non_major_minor_patch() {
58        // Must be exactly three dot-separated integers (SPEC §9 + the JSON schema).
59        assert!(parse_major("3").is_err()); // one component
60        assert!(parse_major("0.1").is_err()); // two components
61        assert!(parse_major("1.2.3.4").is_err()); // four components
62        assert!(parse_major("0.1.0-rc1").is_err()); // pre-release suffix
63    }
64
65    #[test]
66    fn rejects_leading_zeros() {
67        // SPEC §9: each component is `0` or a non-zero digit followed by digits — no leading zeros.
68        assert!(parse_major("01.2.0").is_err());
69        assert!(parse_major("1.02.0").is_err());
70        assert!(parse_major("1.2.00").is_err());
71        // A lone zero component is fine.
72        assert_eq!(parse("0.0.0").unwrap(), (0, 0, 0));
73        assert_eq!(parse("10.20.30").unwrap(), (10, 20, 30));
74    }
75
76    #[test]
77    fn rejects_garbage() {
78        assert!(parse_major("").is_err());
79        assert!(parse_major("x.y.z").is_err());
80        assert!(parse_major("-1.0.0").is_err());
81    }
82}