ledvar-core 0.1.0

Reference implementation of the Ledvar protocol: data model, canonical content-addressed hashing, and well-formedness.
Documentation
//! Protocol version handling (SPEC §10). Only the MAJOR is contract-significant.

use crate::error::Error;

/// Parse a `MAJOR.MINOR.PATCH` string into its three numeric components.
///
/// The form is exactly **three dot-separated integers**, each either `0` or a non-zero digit
/// followed by digits — i.e. **no leading zeros**, no sign, no pre-release/build suffix (SPEC §9:
/// `01.2.0`, `1.2.0-rc1`, `"0"`, `"0.1"`, `"1.2.3.4"` are all rejected). This matches the tightened
/// JSON schema pattern `^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`, so `validate` and the
/// schema agree on what is well-formed.
///
/// ```
/// assert_eq!(ledvar_core::parse_major("0.3.1").unwrap(), 0);
/// assert!(ledvar_core::parse_major("0.3").is_err());   // not MAJOR.MINOR.PATCH
/// assert!(ledvar_core::parse_major("01.2.0").is_err()); // leading zero
/// ```
pub fn parse(version: &str) -> Result<(u64, u64, u64), Error> {
    let bad = || Error::BadVersion(version.to_string());
    // Exactly three components.
    let parts: [&str; 3] = version
        .split('.')
        .collect::<Vec<_>>()
        .try_into()
        .map_err(|_| bad())?;
    let mut nums = [0u64; 3];
    for (slot, p) in nums.iter_mut().zip(parts) {
        // Each: all ASCII digits, non-empty, and no leading zero (a lone "0" is fine).
        if p.is_empty() || !p.bytes().all(|b| b.is_ascii_digit()) {
            return Err(bad());
        }
        if p.len() > 1 && p.starts_with('0') {
            return Err(bad());
        }
        *slot = p.parse::<u64>().map_err(|_| bad())?;
    }
    Ok((nums[0], nums[1], nums[2]))
}

/// Parse just the MAJOR (only MAJOR is contract-significant from MAJOR ≥ 1). Thin wrapper over
/// [`parse`]; the full grammar (including MINOR/PATCH and no-leading-zeros) is still enforced.
pub fn parse_major(version: &str) -> Result<u64, Error> {
    parse(version).map(|(major, _, _)| major)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_major_of_well_formed() {
        assert_eq!(parse_major("0.1.0").unwrap(), 0);
        assert_eq!(parse_major("12.4.7").unwrap(), 12);
    }

    #[test]
    fn rejects_non_major_minor_patch() {
        // Must be exactly three dot-separated integers (SPEC §9 + the JSON schema).
        assert!(parse_major("3").is_err()); // one component
        assert!(parse_major("0.1").is_err()); // two components
        assert!(parse_major("1.2.3.4").is_err()); // four components
        assert!(parse_major("0.1.0-rc1").is_err()); // pre-release suffix
    }

    #[test]
    fn rejects_leading_zeros() {
        // SPEC §9: each component is `0` or a non-zero digit followed by digits — no leading zeros.
        assert!(parse_major("01.2.0").is_err());
        assert!(parse_major("1.02.0").is_err());
        assert!(parse_major("1.2.00").is_err());
        // A lone zero component is fine.
        assert_eq!(parse("0.0.0").unwrap(), (0, 0, 0));
        assert_eq!(parse("10.20.30").unwrap(), (10, 20, 30));
    }

    #[test]
    fn rejects_garbage() {
        assert!(parse_major("").is_err());
        assert!(parse_major("x.y.z").is_err());
        assert!(parse_major("-1.0.0").is_err());
    }
}