arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! YBF (YEAR.BREAK.FIX) version type (RV2.5).
//!
//! Arcature versions are `YEAR.BREAK.FIX` (ADR-0005 Decision §4): the calendar
//! year, the breaking-generation within the year, and the backward-compatible
//! revision within that generation. All three components are non-negative
//! integers. `2026.1.0` is the canonical textual form.
//!
//! YBF is *not* SemVer and is parsed in-module — no `semver` dependency
//! (AGENTS.md §8 / ADR-0005 invariant 15). The three components map to the
//! three bump kinds declared by change fragments:
//!
//! - `Compatible` → increment FIX (e.g. `2026.1.3 → 2026.1.4`).
//! - `Breaking` → next BREAK, FIX reset to 0 (e.g. `2026.1.5 → 2026.2.0`).
//! - Year rollover → increment YEAR, BREAK and FIX reset to 0 (e.g.
//!   `2026.1.5 → 2027.0.0`). The year is calendar-based; rolling into a new
//!   calendar year starts a new BREAK generation `0`.
//!
//! Parsing is strict: exactly three dot-separated base-10 integers, no
//! pre-release/build metadata, no leading `v`, no leading zeros. Hostile
//! input produces a [`YbfError`], never a panic (AGENTS.md §17).

use std::cmp::Ordering;
use std::fmt;

/// A parsed YBF version `YEAR.BREAK.FIX`.
///
/// Ordering is lexicographic by `(year, break_, fix)` — this is the natural
/// ordering for version comparison and is used by the range validator to
/// decide whether a requirement admits a candidate version.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Ybf {
    pub(crate) year: u32,
    pub(crate) break_: u32,
    pub(crate) fix: u32,
}

/// The error returned when a YBF string is not a valid `YEAR.BREAK.FIX`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct YbfError {
    pub(crate) input: String,
    pub(crate) reason: String,
}

impl fmt::Display for YbfError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            formatter,
            "invalid YBF version {:?}: {}",
            self.input, self.reason
        )
    }
}

impl std::error::Error for YbfError {}

impl Ybf {
    /// Parse a `YEAR.BREAK.FIX` string. Strict: three base-10 integers
    /// separated by dots, no leading `v`, no leading zeros (except `0`
    /// itself), no extra components.
    pub(crate) fn parse(input: &str) -> Result<Self, YbfError> {
        let mut parts = input.split('.');
        let reason = |msg: &str| YbfError {
            input: input.to_string(),
            reason: msg.to_string(),
        };

        let year = parse_component(parts.next(), "year", input)?;
        let break_ = parse_component(parts.next(), "break", input)?;
        let fix = parse_component(parts.next(), "fix", input)?;
        if parts.next().is_some() {
            return Err(reason("expected exactly 3 components"));
        }

        Ok(Self { year, break_, fix })
    }

    /// Render this version as its canonical `YEAR.BREAK.FIX` text.
    #[allow(dead_code)]
    pub(crate) fn to_string_value(self) -> String {
        format!("{}.{}.{}", self.year, self.break_, self.fix)
    }

    /// Apply a compatible (FIX) bump: `YEAR.BREAK.FIX → YEAR.BREAK.(FIX+1)`.
    #[allow(dead_code)]
    pub(crate) fn bump_fix(self) -> Self {
        Self {
            year: self.year,
            break_: self.break_,
            fix: self.fix.saturating_add(1),
        }
    }

    /// Apply a breaking (BREAK) bump: `YEAR.BREAK.FIX → YEAR.(BREAK+1).0`.
    /// The next breaking generation resets FIX to 0 within the same year.
    #[allow(dead_code)]
    pub(crate) fn bump_break(self) -> Self {
        Self {
            year: self.year,
            break_: self.break_.saturating_add(1),
            fix: 0,
        }
    }

    /// Roll over to the next calendar year: `YEAR.BREAK.FIX → (YEAR+1).0.0`.
    /// A new year starts a fresh BREAK generation 0 with FIX 0.
    #[allow(dead_code)]
    pub(crate) fn bump_year(self) -> Self {
        Self {
            year: self.year.saturating_add(1),
            break_: 0,
            fix: 0,
        }
    }
}

impl Ord for Ybf {
    fn cmp(&self, other: &Self) -> Ordering {
        self.year
            .cmp(&other.year)
            .then(self.break_.cmp(&other.break_))
            .then(self.fix.cmp(&other.fix))
    }
}

impl PartialOrd for Ybf {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl fmt::Display for Ybf {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}.{}.{}", self.year, self.break_, self.fix)
    }
}

/// Parse a single YBF component, rejecting empty, leading-zero, and
/// non-numeric input. `0` is the only allowed value with a leading zero.
fn parse_component(raw: Option<&str>, name: &str, input: &str) -> Result<u32, YbfError> {
    let raw = raw.ok_or_else(|| YbfError {
        input: input.to_string(),
        reason: format!("missing {name} component"),
    })?;
    if raw.is_empty() {
        return Err(YbfError {
            input: input.to_string(),
            reason: format!("empty {name} component"),
        });
    }
    if raw.len() > 1 && raw.starts_with('0') {
        return Err(YbfError {
            input: input.to_string(),
            reason: format!("{name} has a leading zero"),
        });
    }
    if !raw.bytes().all(|b| b.is_ascii_digit()) {
        return Err(YbfError {
            input: input.to_string(),
            reason: format!("{name} is not a base-10 integer"),
        });
    }
    raw.parse::<u32>().map_err(|_| YbfError {
        input: input.to_string(),
        reason: format!("{name} overflows u32"),
    })
}

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

    #[test]
    fn parses_canonical_form() {
        let v = Ybf::parse("2026.1.0").unwrap();
        assert_eq!(
            v,
            Ybf {
                year: 2026,
                break_: 1,
                fix: 0
            }
        );
    }

    #[test]
    fn parses_zero_components() {
        let v = Ybf::parse("2026.0.0").unwrap();
        assert_eq!(
            v,
            Ybf {
                year: 2026,
                break_: 0,
                fix: 0
            }
        );
    }

    #[test]
    fn rejects_leading_v() {
        assert!(Ybf::parse("v2026.1.0").is_err());
    }

    #[test]
    fn rejects_leading_zeros() {
        assert!(Ybf::parse("2026.01.0").is_err());
        assert!(Ybf::parse("02026.1.0").is_err());
        assert!(Ybf::parse("2026.1.00").is_err());
    }

    #[test]
    fn rejects_too_few_components() {
        assert!(Ybf::parse("2026.1").is_err());
        assert!(Ybf::parse("2026").is_err());
    }

    #[test]
    fn rejects_too_many_components() {
        assert!(Ybf::parse("2026.1.0.0").is_err());
    }

    #[test]
    fn rejects_empty_components() {
        assert!(Ybf::parse("2026..0").is_err());
        assert!(Ybf::parse("2026.1.").is_err());
        assert!(Ybf::parse(".1.0").is_err());
    }

    #[test]
    fn rejects_non_numeric() {
        assert!(Ybf::parse("2026.1.x").is_err());
        assert!(Ybf::parse("2026.a.0").is_err());
    }

    #[test]
    fn rejects_prerelease_metadata() {
        assert!(Ybf::parse("2026.1.0-alpha").is_err());
        assert!(Ybf::parse("2026.1.0+build").is_err());
    }

    #[test]
    fn bump_fix_increments_fix_only() {
        let v = Ybf::parse("2026.1.3").unwrap();
        assert_eq!(v.bump_fix(), Ybf::parse("2026.1.4").unwrap());
    }

    #[test]
    fn bump_break_resets_fix() {
        let v = Ybf::parse("2026.1.5").unwrap();
        assert_eq!(v.bump_break(), Ybf::parse("2026.2.0").unwrap());
    }

    #[test]
    fn bump_year_resets_break_and_fix() {
        let v = Ybf::parse("2026.1.5").unwrap();
        assert_eq!(v.bump_year(), Ybf::parse("2027.0.0").unwrap());
    }

    #[test]
    fn ordering_is_lexicographic() {
        let a = Ybf::parse("2026.1.0").unwrap();
        let b = Ybf::parse("2026.1.1").unwrap();
        let c = Ybf::parse("2026.2.0").unwrap();
        let d = Ybf::parse("2027.0.0").unwrap();
        assert!(a < b);
        assert!(b < c);
        assert!(c < d);
    }

    #[test]
    fn display_is_canonical() {
        let v = Ybf::parse("2026.1.0").unwrap();
        assert_eq!(v.to_string(), "2026.1.0");
        assert_eq!(v.to_string_value(), "2026.1.0");
    }
}