jayver 1.0.0

A calendar versioning scheme for binaries developed by Emmett Jayhart
Documentation
//! Parsers for Jayhart Version components, implemented with `nom`.

use nom::{
    bytes::complete::tag,
    character::complete::digit1,
    combinator::{map_res, verify},
    sequence::separated_pair,
    IResult, Parser,
};
use time::{Date, Weekday};

use crate::{
    error::Error,
    version::{Patch, Version, Week, Year},
};

fn no_leading_zero(input: &str) -> bool {
    input.len() == 1 || !input.starts_with('0')
}

/// Parse ISO week-numbering year (minus 2000) from input.
pub fn year(input: &str) -> IResult<&str, Year> {
    // Handle specific cases with specialized parsers
    nom::branch::alt((
        // Case 1: Single digit "0"
        nom::combinator::map(nom::combinator::verify(digit1, |s: &str| s == "0"), |_| 0),
        // Case 2: Negative integers like "-1" (but not "-0" or "-01")
        nom::sequence::preceded(
            nom::bytes::complete::tag("-"),
            nom::combinator::verify(digit1, |s: &str| !s.starts_with('0') && s != "0"),
        )
        .map(|digits: &str| -digits.parse::<Year>().unwrap_or(0)),
        // Case 3: Positive integers with no leading zeros (1, 2, etc.)
        nom::combinator::verify(digit1, |s: &str| !s.starts_with('0') || s.len() == 1)
            .map(|s: &str| s.parse::<Year>().unwrap_or(0)),
    ))
    .parse(input)
}

/// Parse ISO week number from input (1-53).
pub fn week(input: &str) -> IResult<&str, Week> {
    verify(
        map_res(verify(digit1, no_leading_zero), |s: &str| {
            s.parse::<Week>()
                .map_err(|_| Error::parse(s, "invalid week number"))
        }),
        |w: &Week| (1..=53).contains(w),
    )
    .parse(input)
}

/// Parse `Year.Week` ensuring valid ISO 8601 week date combinations.
pub fn year_week(input: &str) -> IResult<&str, (Year, Week)> {
    verify(separated_pair(year, tag("."), week), |(year, week)| {
        // Validate according to ISO 8601 week date rules
        // Convert year to full year (year + 2000)
        let full_year = *year + 2000;
        Date::from_iso_week_date(full_year, *week, Weekday::Monday).is_ok()
    })
    .parse(input)
}

/// Parse patch number from input.
pub fn patch(input: &str) -> IResult<&str, Patch> {
    map_res(verify(digit1, no_leading_zero), |s: &str| {
        s.parse::<Patch>()
            .map_err(|_| Error::parse(s, "invalid patch number"))
    })
    .parse(input)
}

/// Parse full Jayhart version.
pub fn parse_version(input: &str) -> IResult<&str, Version> {
    let (input, ((year, week), patch)) = separated_pair(year_week, tag("."), patch).parse(input)?;
    Ok((input, Version {
        year,
        week,
        patch,
    }))
}

// Compatibility alias for the older name
pub use parse_version as version;

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

    #[test]
    fn test_year() {
        assert_eq!(year("25"), Ok(("", 25)));
        assert_eq!(year("-5"), Ok(("", -5)));
        assert!(year("025").is_err());
    }

    #[test]
    fn test_week() {
        assert_eq!(week("12"), Ok(("", 12)));
        assert!(week("0").is_err());
        assert!(week("54").is_err());
        assert!(week("01").is_err());
    }

    #[test]
    fn test_year_week() {
        assert_eq!(year_week("25.12"), Ok(("", (25, 12))));
        assert!(year_week("25.54").is_err());
        assert!(year_week("25.00").is_err());
    }

    #[test]
    fn test_patch() {
        assert_eq!(patch("1"), Ok(("", 1)));
        assert_eq!(patch("123"), Ok(("", 123)));
        assert!(patch("01").is_err());
    }

    #[test]
    fn test_version() {
        assert_eq!(
            parse_version("25.12.1"),
            Ok(("", Version {
                year: 25,
                week: 12,
                patch: 1
            }))
        );

        assert!(parse_version("25.54.1").is_err());
        assert!(parse_version("25.12.01").is_err());
    }

    #[test]
    fn test_iso_week_date_parsing() {
        // First week of 2021
        assert_eq!(year_week("21.1"), Ok(("", (21, 1))));

        // Last week of 2020 (week 53)
        assert_eq!(year_week("20.53"), Ok(("", (20, 53))));

        // Invalid: week 53 doesn't exist in 2021
        assert!(year_week("21.53").is_err());
    }
}