arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Platform manifest validation (RV2.8).
//!
//! Validates a [`PlatformManifest`] against the real workspace state from
//! `cargo metadata`: every publishable crate must be listed, every listed
//! crate must be publishable, all versions must be valid YBF, and `core`
//! members (`arcature` and `arcature-dx`) must share the same version
//! (ADR-0005 Decision §5 / invariant 3).
//!
//! The manifest must never reference a crate version not published to
//! crates.io (ADR-0005 lines 217-218). This phase validates the manifest
//! against the workspace's declared versions; the registry check (is this
//! version actually on crates.io?) is a future CI boundary — this validator
//! is side-effect-free and does not call the registry (ADR-0005 invariant
//! 15).

use std::collections::BTreeMap;

use super::model::PlatformManifest;
use crate::release::discovered::{CargoMetadata, DiscoveredCrate};
use crate::release::error::Diagnostic;
use crate::release::metadata::CrateMetadata;
use crate::release::version::Ybf;

/// The result of validating a Platform manifest against the workspace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformValidationReport {
    pub(crate) ok: bool,
    pub(crate) findings: Vec<Diagnostic>,
}

/// Validate a [`PlatformManifest`] against the discovered crates and cargo
/// metadata. Side-effect-free. Collects all findings before returning.
pub(crate) fn validate_platform(
    manifest: &PlatformManifest,
    document: &CargoMetadata,
    crates: &[DiscoveredCrate],
) -> PlatformValidationReport {
    let mut findings: Vec<Diagnostic> = Vec::new();

    // The set of publishable crate names from the workspace.
    let publishable: BTreeMap<String, ()> = crates
        .iter()
        .filter_map(|c| match &c.metadata {
            CrateMetadata::Valid(md) if md.publish => Some((c.name.clone(), ())),
            _ => None,
        })
        .collect();

    // Current versions from cargo metadata.
    let current_versions: BTreeMap<String, Ybf> = document
        .packages
        .iter()
        .filter_map(|p| {
            p.version
                .as_deref()
                .and_then(|v| Ybf::parse(v).ok())
                .map(|v| (p.name.clone(), v))
        })
        .collect();

    // 1. Every publishable crate must be in the manifest.
    for name in publishable.keys() {
        if !manifest.crates.contains_key(name) {
            findings.push(Diagnostic {
                crate_name: name.clone(),
                message: "publishable crate missing from Platform manifest".to_string(),
            });
        }
    }

    // 2. Every listed crate must be publishable.
    for name in manifest.crates.keys() {
        if !publishable.contains_key(name) {
            findings.push(Diagnostic {
                crate_name: name.clone(),
                message: "non-publishable or unknown crate in Platform manifest".to_string(),
            });
        }
    }

    // 3. Core members must share the same version.
    let arcature = manifest.crates.get("arcature");
    let arcature_dx = manifest.crates.get("arcature-dx");
    if let (Some(a), Some(dx)) = (arcature, arcature_dx)
        && a != dx
    {
        findings.push(Diagnostic {
            crate_name: "core".to_string(),
            message: format!(
                "core members have mismatched versions: arcature={a}, arcature-dx={dx} \
                 (invariant 3: core is atomic)"
            ),
        });
    }

    // 4. Manifest versions should match the workspace's current versions.
    // This is a warning, not a hard error — the manifest may certify an
    // older published set while the workspace has moved on. But if they
    // match, it confirms the manifest is current.
    for (name, manifest_version) in &manifest.crates {
        if let Some(current) = current_versions.get(name)
            && manifest_version != current
        {
            findings.push(Diagnostic {
                crate_name: name.clone(),
                message: format!(
                    "manifest version {manifest_version} differs from workspace version {current} \
                     (certifies a different published set)"
                ),
            });
        }
    }

    findings.sort_by(|a, b| {
        a.crate_name
            .cmp(&b.crate_name)
            .then(a.message.cmp(&b.message))
    });

    PlatformValidationReport {
        ok: findings.is_empty(),
        findings,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::discovered::{Package, Target};
    use crate::release::metadata::ReleaseMetadata;
    use crate::release::metadata::Role;

    fn package(name: &str, version: &str, metadata: Option<&str>) -> Package {
        Package {
            name: name.to_string(),
            version: Some(version.to_string()),
            manifest_path: format!("/repo/crates/{name}/Cargo.toml"),
            publish: None,
            metadata: metadata.map(|m| serde_json::from_str(m).unwrap()),
            targets: vec![Target {
                kind: vec!["lib".to_string()],
            }],
            dependencies: Vec::new(),
        }
    }

    fn discovered(name: &str, unit: &str) -> DiscoveredCrate {
        DiscoveredCrate {
            name: name.to_string(),
            manifest_path: std::path::PathBuf::from(format!("/repo/crates/{name}/Cargo.toml")),
            publishable: true,
            proc_macro: false,
            metadata: CrateMetadata::Valid(ReleaseMetadata {
                publish: true,
                role: Some(if unit == "core" {
                    Role::Facade
                } else {
                    Role::Subsystem
                }),
                release_unit: Some(unit.to_string()),
            }),
        }
    }

    fn manifest(crates: &[(&str, &str)]) -> PlatformManifest {
        PlatformManifest {
            platform: super::super::model::PlatformVersion {
                year: 2026,
                break_: 1,
            },
            crates: crates
                .iter()
                .map(|(n, v)| (n.to_string(), Ybf::parse(v).unwrap()))
                .collect(),
        }
    }

    #[test]
    fn valid_manifest_passes() {
        let doc = CargoMetadata {
            packages: vec![package(
                "arcature-auth",
                "2026.1.0",
                Some(
                    r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
                ),
            )],
        };
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let m = manifest(&[("arcature-auth", "2026.1.0")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(report.ok);
        assert!(report.findings.is_empty());
    }

    #[test]
    fn missing_crate_is_finding() {
        let doc = CargoMetadata {
            packages: vec![
                package(
                    "arcature-auth",
                    "2026.1.0",
                    Some(
                        r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
                    ),
                ),
                package(
                    "arcature-db",
                    "2026.1.0",
                    Some(
                        r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-db"}}"#,
                    ),
                ),
            ],
        };
        let crates = vec![
            discovered("arcature-auth", "arcature-auth"),
            discovered("arcature-db", "arcature-db"),
        ];
        let m = manifest(&[("arcature-auth", "2026.1.0")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(!report.ok);
        assert!(
            report
                .findings
                .iter()
                .any(|f| f.crate_name == "arcature-db" && f.message.contains("missing"))
        );
    }

    #[test]
    fn unknown_crate_is_finding() {
        let doc = CargoMetadata {
            packages: vec![package(
                "arcature-auth",
                "2026.1.0",
                Some(
                    r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
                ),
            )],
        };
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let m = manifest(&[("arcature-auth", "2026.1.0"), ("unknown-crate", "2026.1.0")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(!report.ok);
        assert!(
            report
                .findings
                .iter()
                .any(|f| f.crate_name == "unknown-crate" && f.message.contains("non-publishable"))
        );
    }

    #[test]
    fn core_mismatch_is_finding() {
        let doc = CargoMetadata {
            packages: vec![
                package(
                    "arcature",
                    "2026.1.0",
                    Some(r#"{"arcature":{"publish":true,"role":"facade","release-unit":"core"}}"#),
                ),
                package(
                    "arcature-dx",
                    "2026.1.0",
                    Some(
                        r#"{"arcature":{"publish":true,"role":"proc-macro","release-unit":"core"}}"#,
                    ),
                ),
            ],
        };
        let crates = vec![
            discovered("arcature", "core"),
            discovered("arcature-dx", "core"),
        ];
        let m = manifest(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.1")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(!report.ok);
        assert!(
            report
                .findings
                .iter()
                .any(|f| f.crate_name == "core" && f.message.contains("mismatched"))
        );
    }

    #[test]
    fn version_mismatch_is_finding() {
        let doc = CargoMetadata {
            packages: vec![package(
                "arcature-auth",
                "2026.1.0",
                Some(
                    r#"{"arcature":{"publish":true,"role":"subsystem","release-unit":"arcature-auth"}}"#,
                ),
            )],
        };
        let crates = vec![discovered("arcature-auth", "arcature-auth")];
        let m = manifest(&[("arcature-auth", "2026.1.1")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(!report.ok);
        assert!(
            report
                .findings
                .iter()
                .any(|f| f.crate_name == "arcature-auth" && f.message.contains("differs"))
        );
    }

    #[test]
    fn core_match_passes() {
        let doc = CargoMetadata {
            packages: vec![
                package(
                    "arcature",
                    "2026.1.0",
                    Some(r#"{"arcature":{"publish":true,"role":"facade","release-unit":"core"}}"#),
                ),
                package(
                    "arcature-dx",
                    "2026.1.0",
                    Some(
                        r#"{"arcature":{"publish":true,"role":"proc-macro","release-unit":"core"}}"#,
                    ),
                ),
            ],
        };
        let crates = vec![
            discovered("arcature", "core"),
            discovered("arcature-dx", "core"),
        ];
        let m = manifest(&[("arcature", "2026.1.0"), ("arcature-dx", "2026.1.0")]);
        let report = validate_platform(&m, &doc, &crates);
        assert!(report.ok);
    }
}