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}