arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Crate version tag naming (RV2.9 / ADR-0005 Decision §6).
//!
//! Crate version tags are **outputs of publishing**, created only after
//! the registry confirms the exact target version (ADR-0005 invariant
//! 19). They are never the selection source for publishing (invariant 17).
//!
//! The tag format is `<crate>-v<YBF>` — e.g. `arcature-auth-v2026.1.7`.
//! This module owns only the format and parsing; tag creation (git tag)
//! is a boundary trait in the executor.

use crate::release::version::Ybf;

/// A parsed crate version tag `<crate>-v<YBF>`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CrateTag {
    pub(crate) crate_name: String,
    pub(crate) version: Ybf,
}

impl CrateTag {
    /// Build a tag string for a crate and version. This is the canonical
    /// format used after publish success.
    pub(crate) fn render(crate_name: &str, version: &Ybf) -> String {
        format!("{crate_name}-v{version}")
    }
}

impl std::fmt::Display for CrateTag {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "{}-v{}", self.crate_name, self.version)
    }
}

/// Parse a crate version tag `<crate>-v<YBF>`. Returns `None` if the
/// string is not a valid crate tag (used by trigger filters in RV2.10
/// to distinguish crate tags from Platform `v*` tags and transaction
/// `release-*` tags).
pub(crate) fn parse_crate_tag(tag: &str) -> Option<CrateTag> {
    // The tag is `<crate>-v<YBF>`. The crate name may contain hyphens
    // (e.g. `arcature-auth`), so we split on the last `-v` that is
    // followed by a digit.
    let dash_v = tag.rfind("-v")?;
    let after = &tag[dash_v + 2..];

    // The part after `-v` must start with a digit (YBF starts with a year).
    let first = after.chars().next()?;
    if !first.is_ascii_digit() {
        return None;
    }

    let crate_name = &tag[..dash_v];
    let version = Ybf::parse(after).ok()?;

    if crate_name.is_empty() {
        return None;
    }

    Some(CrateTag {
        crate_name: crate_name.to_string(),
        version,
    })
}

/// Check whether a tag string is a crate version tag (`<crate>-v<YBF>`).
/// Used by trigger filters to distinguish the three tag kinds:
/// crate tags, Platform `v*` tags, and transaction `release-*` tags.
#[allow(dead_code)]
pub(crate) fn is_crate_tag(tag: &str) -> bool {
    parse_crate_tag(tag).is_some()
}

/// Check whether a tag string is a Platform tag (`v<YEAR.BREAK>`).
#[allow(dead_code)]
pub(crate) fn is_platform_tag(tag: &str) -> bool {
    // Platform tags are `v<YEAR.BREAK>` — starts with `v` followed by
    // two dot-separated numbers. No crate prefix.
    if let Some(rest) = tag.strip_prefix('v') {
        let parts: Vec<&str> = rest.split('.').collect();
        if parts.len() == 2 {
            return parts
                .iter()
                .all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
        }
    }
    false
}

/// Check whether a tag string is a transaction tag (`release-*`).
#[allow(dead_code)]
pub(crate) fn is_transaction_tag(tag: &str) -> bool {
    tag.starts_with("release-")
}

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

    #[test]
    fn render_crate_tag() {
        let v = Ybf::parse("2026.1.7").unwrap();
        assert_eq!(
            CrateTag::render("arcature-auth", &v),
            "arcature-auth-v2026.1.7"
        );
    }

    #[test]
    fn parse_valid_crate_tag() {
        let tag = parse_crate_tag("arcature-auth-v2026.1.7").expect("parses");
        assert_eq!(tag.crate_name, "arcature-auth");
        assert_eq!(tag.version, Ybf::parse("2026.1.7").unwrap());
    }

    #[test]
    fn parse_crate_tag_with_multi_hyphen_name() {
        let tag = parse_crate_tag("arcature-dx-v2026.2.0").expect("parses");
        assert_eq!(tag.crate_name, "arcature-dx");
        assert_eq!(tag.version, Ybf::parse("2026.2.0").unwrap());
    }

    #[test]
    fn parse_invalid_crate_tag() {
        assert!(parse_crate_tag("v2026.1.0").is_none());
        assert!(parse_crate_tag("release-2026-08-15.01").is_none());
        assert!(parse_crate_tag("arcature-auth-vnot-a-version").is_none());
        assert!(parse_crate_tag("-v2026.1.0").is_none());
    }

    #[test]
    fn is_crate_tag_classifier() {
        assert!(is_crate_tag("arcature-auth-v2026.1.7"));
        assert!(!is_crate_tag("v2026.1.0"));
        assert!(!is_crate_tag("release-2026-08-15.01"));
    }

    #[test]
    fn is_platform_tag_classifier() {
        assert!(is_platform_tag("v2026.1"));
        assert!(is_platform_tag("v2027.0"));
        assert!(!is_platform_tag("arcature-auth-v2026.1.7"));
        assert!(!is_platform_tag("release-2026-08-15.01"));
    }

    #[test]
    fn is_transaction_tag_classifier() {
        assert!(is_transaction_tag("release-2026-08-15.01"));
        assert!(!is_transaction_tag("v2026.1"));
        assert!(!is_transaction_tag("arcature-auth-v2026.1.7"));
    }

    #[test]
    fn display_tag() {
        let tag = CrateTag {
            crate_name: "arcature-auth".to_string(),
            version: Ybf::parse("2026.1.7").unwrap(),
        };
        assert_eq!(tag.to_string(), "arcature-auth-v2026.1.7");
    }

    #[test]
    fn three_tag_kinds_mutually_exclusive() {
        // The three tag kinds should never overlap.
        assert!(!is_crate_tag("v2026.1"));
        assert!(!is_crate_tag("release-2026-08-15.01"));
        assert!(!is_platform_tag("arcature-auth-v2026.1.7"));
        assert!(!is_platform_tag("release-2026-08-15.01"));
        assert!(!is_transaction_tag("v2026.1"));
        assert!(!is_transaction_tag("arcature-auth-v2026.1.7"));
    }
}