arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc release changes validate` — side-effect-free change-fragment validation.
//!
//! Loads cargo metadata, discovers the known release units, loads the
//! pending change fragments from `changes/`, and validates them. Writes
//! nothing to disk and makes no network calls. This is the CI gate for
//! change-fragment integrity (ADR-0005 invariant 8).

use std::collections::BTreeSet;
use std::path::PathBuf;

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

/// The directory holding pending change fragments.
const CHANGES_DIR: &str = "changes";

pub(crate) fn run_changes_validate(format: OutputFormat) -> Result<(), CommandError> {
    // Discover the known release units from cargo metadata.
    let document = release::load_metadata()?;
    let crates = release::discover(&document);
    release::validate(&crates)
        .map_err(|diagnostics| CommandError::Release(ReleaseError::Validation(diagnostics)))?;

    // Collect the known unit names from the validated metadata.
    let known_units: BTreeSet<String> = crates
        .iter()
        .filter_map(|c| match &c.metadata {
            release::CrateMetadata::Valid(md) if md.publish => md.release_unit.clone(),
            _ => None,
        })
        .collect();

    // Load and validate change fragments.
    let changes_dir = PathBuf::from(CHANGES_DIR);
    let (fragments, parse_errors) =
        release::load_fragments_files(&changes_dir).map_err(ReleaseError::from)?;

    if !parse_errors.is_empty() {
        print_parse_errors(&parse_errors, format)?;
        let diagnostics = parse_errors
            .into_iter()
            .map(|(name, msg)| release::Diagnostic {
                crate_name: name,
                message: msg,
            })
            .collect();
        return Err(CommandError::Release(ReleaseError::Validation(diagnostics)));
    }

    match release::validate_fragments(&fragments, &known_units) {
        Ok(change_report) => {
            print_ok(&change_report, format)?;
            Ok(())
        }
        Err(diagnostics) => {
            print_fail(&diagnostics, format)?;
            let wrapped: Vec<release::Diagnostic> = diagnostics
                .into_iter()
                .map(|msg| release::Diagnostic {
                    crate_name: "changes".to_string(),
                    message: msg,
                })
                .collect();
            Err(CommandError::Release(ReleaseError::Validation(wrapped)))
        }
    }
}

fn print_ok(report: &release::ChangeReport, format: OutputFormat) -> Result<(), CommandError> {
    match format {
        OutputFormat::Human => {
            if report.changed_units.is_empty() {
                println!("no pending change fragments in changes/");
            } else {
                println!(
                    "change fragments OK: {} entries across {} unit(s)",
                    report.entry_count,
                    report.changed_units.len(),
                );
                for (unit, kind) in &report.changed_units {
                    println!("  {unit}: {kind:?}");
                }
            }
            Ok(())
        }
        OutputFormat::Json => {
            let payload = serde_json::json!({
                "ok": true,
                "entry_count": report.entry_count,
                "changed_units": report.changed_units.iter()
                    .map(|(unit, kind)| (unit.clone(), format!("{kind:?}")))
                    .collect::<std::collections::BTreeMap<_, _>>(),
            });
            println!("{}", serde_json::to_string_pretty(&payload)?);
            Ok(())
        }
    }
}

fn print_fail(diagnostics: &[String], format: OutputFormat) -> Result<(), CommandError> {
    match format {
        OutputFormat::Human => {
            eprintln!("change fragment validation failed ({}):", diagnostics.len());
            for diagnostic in diagnostics {
                eprintln!("  {diagnostic}");
            }
            Ok(())
        }
        OutputFormat::Json => {
            let payload = serde_json::json!({
                "ok": false,
                "diagnostics": diagnostics,
            });
            println!("{}", serde_json::to_string_pretty(&payload)?);
            Ok(())
        }
    }
}

fn print_parse_errors(
    errors: &[(String, String)],
    format: OutputFormat,
) -> Result<(), CommandError> {
    match format {
        OutputFormat::Human => {
            eprintln!("change fragment parse errors ({}):", errors.len());
            for (name, msg) in errors {
                eprintln!("  {name}: {msg}");
            }
            Ok(())
        }
        OutputFormat::Json => {
            let payload = serde_json::json!({
                "ok": false,
                "parse_errors": errors.iter().map(|(n, m)| {
                    serde_json::json!({"file": n, "error": m})
                }).collect::<Vec<_>>(),
            });
            println!("{}", serde_json::to_string_pretty(&payload)?);
            Ok(())
        }
    }
}