arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Tag and release policy (RV2.11).
//!
//! Formalizes the three distinct tag kinds and their rules per
//! ADR-0005 Decision §6 / invariants 17, 19, 20, 21:
//!
//! 1. **Crate version tags** (`<crate>-v<YBF>`, e.g.
//!    `arcature-auth-v2026.1.7`) — **outputs of publishing**, created
//!    only after the registry confirms that exact target version. They
//!    are never the selection source for publishing (invariant 17/19).
//!
//! 2. **Platform tags** (`v<YEAR.BREAK>`, e.g. `v2026.3.0`) — certify a
//!    version set. They invoke Platform certification only and never
//!    publish crates (invariant 21).
//!
//! 3. **Transaction tags** (`release-<yyyymmdd>-<nn>`, e.g.
//!    `release-2026-08-15.01`) — optional operator UX that points at the
//!    committed Release Plan. They carry no version semantics (ADR-0005
//!    Consequences).
//!
//! The three tag kinds are distinct and non-triggering across workflows.
//! The generated-tag loop is prevented structurally by the `on:` filters
//! (RV2.10), not by incidental suppression (invariant 20).

use crate::release::publish::tag::{is_crate_tag, is_platform_tag, is_transaction_tag};

/// The kind of a release tag, as classified by the tag policy.
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TagKind {
    /// A crate version tag: `<crate>-v<YBF>`. Output of publishing.
    CrateVersion,
    /// A Platform tag: `v<YEAR.BREAK>`. Certifies a version set.
    Platform,
    /// A transaction tag: `release-*`. Optional operator UX.
    Transaction,
}

/// The policy violation when a tag does not conform to any known kind.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TagPolicyError {
    pub(crate) tag: String,
    pub(crate) reason: String,
}

impl std::fmt::Display for TagPolicyError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            formatter,
            "tag {:?} does not conform to any release tag policy: {}",
            self.tag, self.reason
        )
    }
}

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

/// Classify a tag string into its [`TagKind`]. Returns an error if the tag
/// does not conform to any of the three known kinds.
///
/// This is the single policy entry point for tag classification. The
/// workflow trigger filters (RV2.10) use this classification to ensure
/// the three kinds are disjoint and non-triggering across workflows.
#[allow(dead_code)]
pub(crate) fn classify_tag(tag: &str) -> Result<TagKind, TagPolicyError> {
    if is_crate_tag(tag) {
        Ok(TagKind::CrateVersion)
    } else if is_platform_tag(tag) {
        Ok(TagKind::Platform)
    } else if is_transaction_tag(tag) {
        Ok(TagKind::Transaction)
    } else {
        Err(TagPolicyError {
            tag: tag.to_string(),
            reason: "expected one of: crate-version (<crate>-v<YBF>), \
                     platform (v<YEAR.BREAK>), or transaction (release-*)"
                .to_string(),
        })
    }
}

/// The trigger action a workflow should take for a given tag kind.
///
/// This encodes the structural guarantee (ADR-0005 invariant 20):
/// - `release-transaction.yml` accepts `Transaction` only.
/// - `platform-certification.yml` accepts `Platform` only.
/// - Neither accepts `CrateVersion` (generated tags must not re-trigger).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TriggerAction {
    /// The tag triggers this workflow.
    Accept,
    /// The tag does not trigger this workflow; it must be rejected.
    Reject,
}

/// Decide whether a tag of the given kind should trigger the
/// release-transaction workflow (the selective publisher).
///
/// Per ADR-0005 §7: accepts `Transaction` tags, rejects `Platform` and
/// `CrateVersion` tags.
#[allow(dead_code)]
pub(crate) fn transaction_trigger_action(kind: TagKind) -> TriggerAction {
    match kind {
        TagKind::Transaction => TriggerAction::Accept,
        TagKind::Platform | TagKind::CrateVersion => TriggerAction::Reject,
    }
}

/// Decide whether a tag of the given kind should trigger the
/// platform-certification workflow.
///
/// Per ADR-0005 §7: accepts `Platform` tags only, rejects `Transaction`
/// and `CrateVersion` tags.
#[allow(dead_code)]
pub(crate) fn platform_trigger_action(kind: TagKind) -> TriggerAction {
    match kind {
        TagKind::Platform => TriggerAction::Accept,
        TagKind::Transaction | TagKind::CrateVersion => TriggerAction::Reject,
    }
}

/// Whether a crate version tag may be created before its crate is
/// published. Per ADR-0005 invariant 19, the answer is always no: a
/// crate tag is absent until crates.io confirms that exact target
/// version. No pre-publish tagging.
#[allow(dead_code)]
pub(crate) fn allows_prepublish_tagging(_kind: TagKind) -> bool {
    false
}

/// Validate that a crate version tag name matches the expected crate and
/// version. This is the post-publish verification: after the registry
/// confirms a version, the tag created must match exactly.
#[allow(dead_code)]
pub(crate) fn verify_crate_tag(
    tag: &str,
    expected_crate: &str,
    expected_version: &crate::release::version::Ybf,
) -> Result<(), TagPolicyError> {
    let parsed =
        crate::release::publish::tag::parse_crate_tag(tag).ok_or_else(|| TagPolicyError {
            tag: tag.to_string(),
            reason: "not a valid crate version tag".to_string(),
        })?;

    if parsed.crate_name != expected_crate {
        return Err(TagPolicyError {
            tag: tag.to_string(),
            reason: format!(
                "crate name mismatch: expected {expected_crate}, found {}",
                parsed.crate_name
            ),
        });
    }

    if parsed.version != *expected_version {
        return Err(TagPolicyError {
            tag: tag.to_string(),
            reason: format!(
                "version mismatch: expected {expected_version}, found {}",
                parsed.version
            ),
        });
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::version::Ybf;

    // --- classify_tag ---

    #[test]
    fn classify_crate_version_tag() {
        assert_eq!(
            classify_tag("arcature-auth-v2026.1.7").unwrap(),
            TagKind::CrateVersion
        );
        assert_eq!(
            classify_tag("arcature-dx-v2026.2.0").unwrap(),
            TagKind::CrateVersion
        );
    }

    #[test]
    fn classify_platform_tag() {
        assert_eq!(classify_tag("v2026.1").unwrap(), TagKind::Platform);
        assert_eq!(classify_tag("v2027.0").unwrap(), TagKind::Platform);
    }

    #[test]
    fn classify_transaction_tag() {
        assert_eq!(
            classify_tag("release-2026-08-15.01").unwrap(),
            TagKind::Transaction
        );
        assert_eq!(
            classify_tag("release-2026-12-31.99").unwrap(),
            TagKind::Transaction
        );
    }

    #[test]
    fn classify_unknown_tag_errors() {
        assert!(classify_tag("v2026.1.0").is_err());
        assert!(classify_tag("arcature-auth-2026.1.7").is_err());
        assert!(classify_tag("latest").is_err());
        assert!(classify_tag("").is_err());
    }

    // --- transaction_trigger_action ---

    #[test]
    fn transaction_accepts_only_transaction_tags() {
        assert_eq!(
            transaction_trigger_action(TagKind::Transaction),
            TriggerAction::Accept
        );
        assert_eq!(
            transaction_trigger_action(TagKind::Platform),
            TriggerAction::Reject
        );
        assert_eq!(
            transaction_trigger_action(TagKind::CrateVersion),
            TriggerAction::Reject
        );
    }

    // --- platform_trigger_action ---

    #[test]
    fn platform_accepts_only_platform_tags() {
        assert_eq!(
            platform_trigger_action(TagKind::Platform),
            TriggerAction::Accept
        );
        assert_eq!(
            platform_trigger_action(TagKind::Transaction),
            TriggerAction::Reject
        );
        assert_eq!(
            platform_trigger_action(TagKind::CrateVersion),
            TriggerAction::Reject
        );
    }

    // --- allows_prepublish_tagging ---

    #[test]
    fn no_prepublish_tagging_for_any_kind() {
        assert!(!allows_prepublish_tagging(TagKind::CrateVersion));
        assert!(!allows_prepublish_tagging(TagKind::Platform));
        assert!(!allows_prepublish_tagging(TagKind::Transaction));
    }

    // --- verify_crate_tag ---

    #[test]
    fn verify_matching_crate_tag() {
        let version = Ybf::parse("2026.1.7").unwrap();
        assert!(verify_crate_tag("arcature-auth-v2026.1.7", "arcature-auth", &version).is_ok());
    }

    #[test]
    fn verify_crate_tag_name_mismatch() {
        let version = Ybf::parse("2026.1.7").unwrap();
        let error = verify_crate_tag("arcature-cli-v2026.1.7", "arcature-auth", &version)
            .expect_err("name mismatch should fail");
        assert!(error.reason.contains("crate name mismatch"));
    }

    #[test]
    fn verify_crate_tag_version_mismatch() {
        let version = Ybf::parse("2026.1.7").unwrap();
        let error = verify_crate_tag("arcature-auth-v2026.1.8", "arcature-auth", &version)
            .expect_err("version mismatch should fail");
        assert!(error.reason.contains("version mismatch"));
    }

    #[test]
    fn verify_invalid_crate_tag() {
        let version = Ybf::parse("2026.1.7").unwrap();
        assert!(verify_crate_tag("not-a-tag", "arcature-auth", &version).is_err());
    }

    // --- mutual exclusivity (three kinds never overlap) ---

    #[test]
    fn three_tag_kinds_are_mutually_exclusive() {
        let crate_tags = ["arcature-auth-v2026.1.7", "arcature-dx-v2026.2.0"];
        let platform_tags = ["v2026.1", "v2027.0"];
        let transaction_tags = ["release-2026-08-15.01", "release-2026-12-31.99"];

        for tag in crate_tags {
            assert_eq!(classify_tag(tag).unwrap(), TagKind::CrateVersion);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::Platform);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::Transaction);
        }
        for tag in platform_tags {
            assert_eq!(classify_tag(tag).unwrap(), TagKind::Platform);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::CrateVersion);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::Transaction);
        }
        for tag in transaction_tags {
            assert_eq!(classify_tag(tag).unwrap(), TagKind::Transaction);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::CrateVersion);
            assert_ne!(classify_tag(tag).unwrap(), TagKind::Platform);
        }
    }
}