arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc release platform validate` — Platform manifest validation (RV2.8).
//!
//! Loads the Platform manifest from `platform/<platform>.toml`, discovers
//! the workspace's publishable crates from `cargo metadata`, and validates
//! the manifest against the real workspace state. Side-effect-free: no
//! writes, no network (ADR-0005 invariant 15).
//!
//! The manifest must include every publishable crate, list only
//! publishable crates, have core members share the same version, and use
//! valid YBF versions (ADR-0005 Decision §5 / invariant 3).

use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::release::{self, ReleaseError};

/// The directory holding Platform manifests.
const PLATFORM_DIR: &str = "platform";

/// Run the `arc release platform validate` command.
pub(crate) fn run_platform_validate(
    format: OutputFormat,
    platform_name: &str,
) -> Result<(), CommandError> {
    let manifest_path = format!("{PLATFORM_DIR}/{platform_name}.toml");
    let manifest_text = std::fs::read_to_string(&manifest_path).map_err(|e| {
        CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
            crate_name: "platform".to_string(),
            message: format!("cannot read {manifest_path}: {e}"),
        }]))
    })?;

    let manifest = parse_manifest(&manifest_text, platform_name)?;

    let document = release::load_metadata()?;
    let crates = release::discover(&document);
    release::validate(&crates)
        .map_err(|diagnostics| CommandError::Release(ReleaseError::Validation(diagnostics)))?;

    let report = release::validate_platform(&manifest, &document, &crates);

    match format {
        OutputFormat::Human => {
            if report.ok {
                println!("Platform manifest {platform_name}: OK");
                println!("  crates: {}", manifest.crates.len());
                println!("  platform: {}", manifest.platform);
                Ok(())
            } else {
                eprintln!(
                    "Platform manifest {platform_name}: {} finding(s)",
                    report.findings.len()
                );
                for finding in &report.findings {
                    eprintln!("  {finding}");
                }
                Err(CommandError::Release(ReleaseError::Validation(
                    report.findings,
                )))
            }
        }
        OutputFormat::Json => {
            let payload = serde_json::json!({
                "platform": manifest.platform.to_string(),
                "crate_count": manifest.crates.len(),
                "ok": report.ok,
                "findings": report.findings.iter().map(|d| serde_json::json!({
                    "crate": d.crate_name,
                    "message": d.message,
                })).collect::<Vec<_>>(),
            });
            println!("{}", serde_json::to_string_pretty(&payload)?);
            if !report.ok {
                return Err(CommandError::Release(ReleaseError::Validation(
                    report.findings,
                )));
            }
            Ok(())
        }
    }
}

/// Parse a Platform manifest from TOML text. The format is:
/// `platform = "YEAR.BREAK"` then `[crates]` with `name = "VERSION"` entries.
fn parse_manifest(
    text: &str,
    platform_name: &str,
) -> Result<release::PlatformManifest, CommandError> {
    let platform_version = release::PlatformVersion::parse(platform_name).map_err(|e| {
        CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
            crate_name: "platform".to_string(),
            message: format!("{e}"),
        }]))
    })?;

    let mut crates: std::collections::BTreeMap<String, release::Ybf> =
        std::collections::BTreeMap::new();
    let mut in_crates = false;
    let mut found_platform = false;

    for line in text.lines() {
        let trimmed = line.trim();
        if trimmed.starts_with('#') || trimmed.is_empty() {
            continue;
        }
        if trimmed.starts_with('[') {
            in_crates = trimmed == "[crates]";
            continue;
        }
        if trimmed.starts_with("platform") && trimmed.contains('=') {
            found_platform = true;
            continue;
        }
        if in_crates && trimmed.contains('=') {
            let mut parts = trimmed.splitn(2, '=');
            let name = parts.next().unwrap().trim().to_string();
            let value = parts
                .next()
                .unwrap()
                .trim()
                .trim_matches('"')
                .trim_matches('\'');
            let version = release::Ybf::parse(value).map_err(|e| {
                CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
                    crate_name: name.clone(),
                    message: format!("{e}"),
                }]))
            })?;
            crates.insert(name, version);
        }
    }

    if !found_platform {
        return Err(CommandError::Release(ReleaseError::Validation(vec![
            release::Diagnostic {
                crate_name: "platform".to_string(),
                message: "missing `platform` field".to_string(),
            },
        ])));
    }

    Ok(release::PlatformManifest {
        platform: platform_version,
        crates,
    })
}