arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! YBF dependency requirement ranges (RV2.5).
//!
//! A dependency requirement is the `version = "…"` field in a Cargo manifest.
//! Arcature allows three YBF-correct shapes (ADR-0005 Decision §4 / invariant
//! 5) plus the path-only wildcard, and forbids caret:
//!
//! - **Tilde** `~YEAR.BREAK.0` — admits compatible fixes in the same BREAK
//!   generation: `>=YEAR.BREAK.0, <YEAR.(BREAK+1).0`. Used for cross-unit
//!   compatible dependencies. Refuses the next breaking generation.
//! - **Exact** `=YEAR.BREAK.FIX` — admits only that exact version. Used
//!   inside `core` so a published `arcature@V` resolves `arcature-dx@V` and
//!   nothing else (ADR-0005 Decision §2 / invariant 3).
//! - **Caret** `^YEAR.BREAK.FIX` — Cargo expands to `>=YEAR.BREAK.FIX,
//!   <(YEAR+1).0.0`, silently admitting the next breaking generation within
//!   the same year. **Forbidden** for Arcature sibling dependencies because
//!   it breaks YBF-breaking semantics (ADR-0005 invariant 5).
//! - **Wildcard** `*` — a path-only dependency with no registry version. Not
//!   a range at all; `cargo publish` strips these so the validator ignores
//!   them (they never reach the registry).
//!
//! Parsing produces a typed [`Requirement`] and an explicit [`ReqKind`], so
//! the validator can report the forbidden caret with a precise message rather
//! than treating it as an opaque string. All parsing is strict and side
//! effect-free; hostile input is a [`RangeError`], never a panic
//! (AGENTS.md §17).

use std::fmt;

use super::ybf::Ybf;

/// The kind of YBF dependency requirement found in a manifest.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReqKind {
    /// `~YEAR.BREAK.0` — compatible fixes in one generation.
    Tilde,
    /// `=YEAR.BREAK.FIX` — exactly one version.
    Exact,
    /// `^YEAR.BREAK.FIX` — forbidden for Arcature siblings (admits breaking).
    Caret,
    /// `*` — path-only, stripped by `cargo publish`, not a real range.
    Wildcard,
}

/// A parsed YBF dependency requirement.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Requirement {
    pub(crate) kind: ReqKind,
    /// The base version the requirement is anchored on. `None` for the
    /// wildcard, which carries no version.
    pub(crate) base: Option<Ybf>,
}

impl Requirement {
    /// Parse a raw `version = "…"` requirement string. Recognizes tilde,
    /// exact, caret, and the bare wildcard; anything else is an error.
    pub(crate) fn parse(raw: &str) -> Result<Self, RangeError> {
        let trimmed = raw.trim();
        if trimmed.is_empty() {
            return Err(RangeError {
                input: raw.to_string(),
                reason: "empty requirement".to_string(),
            });
        }
        if trimmed == "*" {
            return Ok(Self {
                kind: ReqKind::Wildcard,
                base: None,
            });
        }
        if let Some(rest) = trimmed.strip_prefix('~') {
            let base = Ybf::parse(rest).map_err(|e| RangeError {
                input: raw.to_string(),
                reason: format!("tilde range: {e}"),
            })?;
            return Ok(Self {
                kind: ReqKind::Tilde,
                base: Some(base),
            });
        }
        if let Some(rest) = trimmed.strip_prefix('=') {
            let base = Ybf::parse(rest).map_err(|e| RangeError {
                input: raw.to_string(),
                reason: format!("exact range: {e}"),
            })?;
            return Ok(Self {
                kind: ReqKind::Exact,
                base: Some(base),
            });
        }
        if let Some(rest) = trimmed.strip_prefix('^') {
            let base = Ybf::parse(rest).map_err(|e| RangeError {
                input: raw.to_string(),
                reason: format!("caret range: {e}"),
            })?;
            return Ok(Self {
                kind: ReqKind::Caret,
                base: Some(base),
            });
        }
        // A bare YBF with no operator is treated as a caret by Cargo
        // (`2026.1.0` means `>=2026.1.0, <2027.0.0`). It is the most common
        // forbidden shape, so we classify it as Caret with a precise reason.
        if let Ok(base) = Ybf::parse(trimmed) {
            return Ok(Self {
                kind: ReqKind::Caret,
                base: Some(base),
            });
        }
        Err(RangeError {
            input: raw.to_string(),
            reason: "unrecognized requirement shape".to_string(),
        })
    }

    /// The upper bound (exclusive) the requirement admits, or `None` for
    /// the wildcard / exact (no open upper bound to report). For tilde
    /// `~YEAR.BREAK.0` this is `YEAR.(BREAK+1).0`; for caret it is
    /// `(YEAR+1).0.0` — the bound that admits a breaking generation.
    #[allow(dead_code)]
    pub(crate) fn upper_exclusive(&self) -> Option<Ybf> {
        match self.kind {
            ReqKind::Tilde => self.base.map(|b| Ybf {
                year: b.year,
                break_: b.break_.saturating_add(1),
                fix: 0,
            }),
            ReqKind::Caret => self.base.map(|b| Ybf {
                year: b.year.saturating_add(1),
                break_: 0,
                fix: 0,
            }),
            ReqKind::Exact | ReqKind::Wildcard => None,
        }
    }

    /// Whether the requirement admits the candidate version. Used by the
    /// planner to confirm a dependent's range still accepts a dependency's
    /// new (bumped) version.
    #[allow(dead_code)]
    pub(crate) fn admits(&self, candidate: Ybf) -> bool {
        match self.kind {
            ReqKind::Wildcard => true,
            ReqKind::Exact => self.base.map(|b| b == candidate).unwrap_or(false),
            ReqKind::Tilde | ReqKind::Caret => match self.base {
                Some(base) => {
                    candidate >= base
                        && self
                            .upper_exclusive()
                            .is_some_and(|upper| candidate < upper)
                }
                None => false,
            },
        }
    }
}

/// The error returned when a requirement string is not a recognized
/// YBF-correct range.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RangeError {
    pub(crate) input: String,
    pub(crate) reason: String,
}

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

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

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

    #[test]
    fn parses_tilde() {
        let r = Requirement::parse("~2026.1.0").unwrap();
        assert_eq!(r.kind, ReqKind::Tilde);
        assert_eq!(r.base, Some(Ybf::parse("2026.1.0").unwrap()));
    }

    #[test]
    fn parses_exact() {
        let r = Requirement::parse("=2026.1.0").unwrap();
        assert_eq!(r.kind, ReqKind::Exact);
        assert_eq!(r.base, Some(Ybf::parse("2026.1.0").unwrap()));
    }

    #[test]
    fn parses_caret_explicit() {
        let r = Requirement::parse("^2026.1.0").unwrap();
        assert_eq!(r.kind, ReqKind::Caret);
    }

    #[test]
    fn parses_caret_implicit_bare_version() {
        // A bare YBF is caret by Cargo's rules — forbidden, but still
        // classified so the validator reports a precise message.
        let r = Requirement::parse("2026.1.0").unwrap();
        assert_eq!(r.kind, ReqKind::Caret);
    }

    #[test]
    fn parses_wildcard() {
        let r = Requirement::parse("*").unwrap();
        assert_eq!(r.kind, ReqKind::Wildcard);
        assert!(r.base.is_none());
    }

    #[test]
    fn rejects_empty() {
        assert!(Requirement::parse("").is_err());
    }

    #[test]
    fn rejects_garbage() {
        assert!(Requirement::parse("latest").is_err());
        assert!(Requirement::parse(">=2026.1.0, <2027.0.0").is_err());
    }

    #[test]
    fn rejects_invalid_tilde_base() {
        assert!(Requirement::parse("~2026.1.x").is_err());
    }

    #[test]
    fn tilde_upper_bound_is_next_generation() {
        let r = Requirement::parse("~2026.1.0").unwrap();
        assert_eq!(r.upper_exclusive(), Some(Ybf::parse("2026.2.0").unwrap()));
    }

    #[test]
    fn caret_upper_bound_is_next_year() {
        let r = Requirement::parse("^2026.1.0").unwrap();
        assert_eq!(r.upper_exclusive(), Some(Ybf::parse("2027.0.0").unwrap()));
    }

    #[test]
    fn exact_and_wildcard_have_no_upper_bound() {
        let exact = Requirement::parse("=2026.1.0").unwrap();
        let wild = Requirement::parse("*").unwrap();
        assert!(exact.upper_exclusive().is_none());
        assert!(wild.upper_exclusive().is_none());
    }

    #[test]
    fn tilde_admits_same_generation_fixes() {
        let r = Requirement::parse("~2026.1.0").unwrap();
        assert!(r.admits(Ybf::parse("2026.1.0").unwrap()));
        assert!(r.admits(Ybf::parse("2026.1.5").unwrap()));
    }

    #[test]
    fn tilde_refuses_next_generation() {
        let r = Requirement::parse("~2026.1.0").unwrap();
        assert!(!r.admits(Ybf::parse("2026.2.0").unwrap()));
        assert!(!r.admits(Ybf::parse("2027.0.0").unwrap()));
    }

    #[test]
    fn exact_admits_only_one_version() {
        let r = Requirement::parse("=2026.1.0").unwrap();
        assert!(r.admits(Ybf::parse("2026.1.0").unwrap()));
        assert!(!r.admits(Ybf::parse("2026.1.1").unwrap()));
        assert!(!r.admits(Ybf::parse("2026.2.0").unwrap()));
    }

    #[test]
    fn caret_admits_next_generation_within_year() {
        // This is exactly why caret is forbidden: it admits 2026.2.0, a
        // breaking generation, from a requirement anchored on 2026.1.0.
        let r = Requirement::parse("^2026.1.0").unwrap();
        assert!(r.admits(Ybf::parse("2026.2.0").unwrap()));
    }

    #[test]
    fn caret_refuses_next_year() {
        let r = Requirement::parse("^2026.1.0").unwrap();
        assert!(!r.admits(Ybf::parse("2027.0.0").unwrap()));
    }
}