arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Validate a loaded Release Plan against the current commit and cargo
//! metadata (RV2.9).
//!
//! The publisher derives what to publish from a committed, validated
//! Release Plan — never from a tag name or workflow inputs (ADR-0005
//! invariant 17). A production transaction is pinned to an immutable
//! commit: the plan's `commit` must match the checked-out HEAD sha, and
//! the plan's publish order must be consistent with the real Cargo
//! dependency graph (ADR-0005 invariant 10/18).
//!
//! This module is pure: it takes the loaded plan, the current HEAD sha,
//! the discovered crates, and the unit graph, and returns a list of
//! diagnostics (empty on success). No I/O, no network (ADR-0005 invariant
//! 15).

use std::collections::{BTreeMap, BTreeSet};

use crate::release::discovered::DiscoveredCrate;
use crate::release::error::Diagnostic;
use crate::release::graph::UnitGraph;
use crate::release::metadata::CrateMetadata;
use crate::release::publish::plan::LoadedPlan;

/// Validate a loaded Release Plan. Returns an empty list if the plan is
/// sound; otherwise one diagnostic per finding.
///
/// Checks:
/// 1. The plan's commit matches `head_sha` (ADR-0005 invariant 18).
/// 2. Every crate in the plan is publishable (exists in cargo metadata
///    with `publish = true`).
/// 3. The plan's topo order is consistent with the real unit graph.
/// 4. Core atomicity: if `arcature` or `arcature-dx` is in the plan, both
///    must be present with the same version (ADR-0005 invariant 3).
/// 5. Order values are unique and sequential starting from 1.
pub(crate) fn validate_plan(
    plan: &LoadedPlan,
    head_sha: &str,
    crates: &[DiscoveredCrate],
    unit_graph: &UnitGraph,
) -> Vec<Diagnostic> {
    let mut findings = Vec::new();

    // 1. Commit must match HEAD.
    if plan.commit != head_sha {
        findings.push(Diagnostic {
            crate_name: "plan".to_string(),
            message: format!(
                "plan commit {} does not match HEAD {}",
                plan.commit, head_sha
            ),
        });
    }

    // Map of publishable crate names.
    let publishable: BTreeMap<&str, &DiscoveredCrate> = crates
        .iter()
        .filter_map(|c| match &c.metadata {
            CrateMetadata::Valid(md) if md.publish => Some((c.name.as_str(), c)),
            _ => None,
        })
        .collect();

    // 2. Every plan entry must be a publishable crate.
    for entry in plan.entries.values() {
        if !publishable.contains_key(entry.crate_name.as_str()) {
            findings.push(Diagnostic {
                crate_name: entry.crate_name.clone(),
                message: "crate in plan is not publishable".to_string(),
            });
        }
    }

    // 3. Topo order consistency: the plan's per-unit order must not violate
    //    the real unit graph dependencies. If unit A depends on unit B,
    //    every crate in A must have an order greater than every crate in B.
    let plan_unit_of: BTreeMap<&str, &str> = plan
        .entries
        .values()
        .filter_map(|e| {
            publishable
                .get(e.crate_name.as_str())
                .and_then(|c| match &c.metadata {
                    CrateMetadata::Valid(md) => md.release_unit.as_deref(),
                    _ => None,
                })
                .map(|unit| (e.crate_name.as_str(), unit))
        })
        .collect();

    let plan_units: BTreeSet<&str> = plan_unit_of.values().copied().collect();

    for unit_name in &plan_units {
        let node = match unit_graph.nodes.get(*unit_name) {
            Some(n) => n,
            None => {
                findings.push(Diagnostic {
                    crate_name: "plan".to_string(),
                    message: format!("unit {unit_name} not found in dependency graph"),
                });
                continue;
            }
        };

        for dep_unit in node.dependencies.keys() {
            // Only check if the dependency unit is also in the plan.
            if !plan_units.contains(dep_unit.as_str()) {
                continue;
            }

            // Every crate in `unit_name` must have a higher order than
            // every crate in `dep_unit`.
            let min_dependent_order = plan
                .entries
                .values()
                .filter(|e| {
                    plan_unit_of
                        .get(e.crate_name.as_str())
                        .is_some_and(|u| *u == *unit_name)
                })
                .map(|e| e.order)
                .min()
                .unwrap_or(usize::MAX);

            let max_dependency_order = plan
                .entries
                .values()
                .filter(|e| {
                    plan_unit_of
                        .get(e.crate_name.as_str())
                        .is_some_and(|u| *u == dep_unit.as_str())
                })
                .map(|e| e.order)
                .max()
                .unwrap_or(0);

            if min_dependent_order <= max_dependency_order {
                findings.push(Diagnostic {
                    crate_name: "plan".to_string(),
                    message: format!(
                        "topological order violated: unit {unit_name} (min order {min_dependent_order}) \
                         must publish after its dependency {dep_unit} (max order {max_dependency_order})"
                    ),
                });
            }
        }
    }

    // 4. Core atomicity: if arcature or arcature-dx is in the plan, both
    //    must be present with the same version.
    let arcature = plan.entries.get("arcature");
    let arcature_dx = plan.entries.get("arcature-dx");
    match (arcature, arcature_dx) {
        (Some(a), Some(b)) => {
            if a.version != b.version {
                findings.push(Diagnostic {
                    crate_name: "core".to_string(),
                    message: format!(
                        "core atomicity violated: arcature {} != arcature-dx {}",
                        a.version, b.version
                    ),
                });
            }
        }
        (Some(_), None) => {
            findings.push(Diagnostic {
                crate_name: "core".to_string(),
                message: "core atomicity violated: arcature in plan but arcature-dx is not"
                    .to_string(),
            });
        }
        (None, Some(_)) => {
            findings.push(Diagnostic {
                crate_name: "core".to_string(),
                message: "core atomicity violated: arcature-dx in plan but arcature is not"
                    .to_string(),
            });
        }
        (None, None) => {}
    }

    // 5. Order values must be unique.
    let mut seen_orders: BTreeSet<usize> = BTreeSet::new();
    for entry in plan.entries.values() {
        if !seen_orders.insert(entry.order) {
            findings.push(Diagnostic {
                crate_name: "plan".to_string(),
                message: format!("duplicate order value {}", entry.order),
            });
        }
    }

    findings
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::discovered::DiscoveredCrate;
    use crate::release::graph::{UnitGraph, UnitNode};
    use crate::release::metadata::{CrateMetadata, ReleaseMetadata};
    use crate::release::publish::plan::{LoadedEntry, LoadedPlan};
    use crate::release::version::Ybf;
    use std::collections::{BTreeMap, BTreeSet};

    const SHA: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";

    fn make_crate(name: &str, unit: &str, publish: bool) -> DiscoveredCrate {
        DiscoveredCrate {
            name: name.to_string(),
            manifest_path: std::path::PathBuf::from(format!("crates/{name}/Cargo.toml")),
            proc_macro: false,
            publishable: publish,
            metadata: CrateMetadata::Valid(ReleaseMetadata {
                publish,
                role: Some(crate::release::metadata::Role::Subsystem),
                release_unit: Some(unit.to_string()),
            }),
        }
    }

    fn make_plan(entries: Vec<LoadedEntry>) -> LoadedPlan {
        let map: BTreeMap<String, LoadedEntry> = entries
            .into_iter()
            .map(|e| (e.crate_name.clone(), e))
            .collect();
        LoadedPlan {
            transaction_id: "2026-08-15.01".to_string(),
            commit: SHA.to_string(),
            entries: map,
        }
    }

    fn entry(name: &str, version: &str, order: usize) -> LoadedEntry {
        LoadedEntry {
            crate_name: name.to_string(),
            version: Ybf::parse(version).unwrap(),
            order,
        }
    }

    fn two_unit_graph() -> UnitGraph {
        // unit "a" depends on nothing; unit "b" depends on "a".
        let mut nodes = BTreeMap::new();
        nodes.insert(
            "a".to_string(),
            UnitNode {
                name: "a".to_string(),
                members: BTreeSet::from(["crate-a".to_string()]),
                dependencies: BTreeMap::new(),
            },
        );
        nodes.insert(
            "b".to_string(),
            UnitNode {
                name: "b".to_string(),
                members: BTreeSet::from(["crate-b".to_string()]),
                dependencies: BTreeMap::from([("a".to_string(), BTreeSet::new())]),
            },
        );
        UnitGraph { nodes }
    }

    #[test]
    fn valid_plan_no_findings() {
        let crates = vec![
            make_crate("crate-a", "a", true),
            make_crate("crate-b", "b", true),
        ];
        let plan = make_plan(vec![
            entry("crate-a", "2026.1.1", 1),
            entry("crate-b", "2026.1.1", 2),
        ]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(findings.is_empty(), "expected no findings: {findings:?}");
    }

    #[test]
    fn commit_mismatch_reported() {
        let crates = vec![make_crate("crate-a", "a", true)];
        let plan = make_plan(vec![entry("crate-a", "2026.1.1", 1)]);
        let findings = validate_plan(
            &plan,
            "0000000000000000000000000000000000000000",
            &crates,
            &two_unit_graph(),
        );
        assert_eq!(findings.len(), 1);
        assert!(findings[0].message.contains("does not match HEAD"));
    }

    #[test]
    fn non_publishable_crate_reported() {
        let crates = vec![make_crate("crate-a", "a", false)];
        let plan = make_plan(vec![entry("crate-a", "2026.1.1", 1)]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(
            findings
                .iter()
                .any(|f| f.message.contains("not publishable"))
        );
    }

    #[test]
    fn topo_order_violation_reported() {
        // crate-b (unit b, depends on a) has order 1, crate-a has order 2.
        // This violates topo order: b must come after a.
        let crates = vec![
            make_crate("crate-a", "a", true),
            make_crate("crate-b", "b", true),
        ];
        let plan = make_plan(vec![
            entry("crate-b", "2026.1.1", 1),
            entry("crate-a", "2026.1.1", 2),
        ]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(
            findings
                .iter()
                .any(|f| f.message.contains("topological order violated"))
        );
    }

    #[test]
    fn core_atomicity_version_mismatch() {
        let crates = vec![
            make_crate("arcature", "core", true),
            make_crate("arcature-dx", "core", true),
        ];
        let plan = make_plan(vec![
            entry("arcature", "2026.1.5", 1),
            entry("arcature-dx", "2026.1.6", 2),
        ]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(
            findings
                .iter()
                .any(|f| f.message.contains("core atomicity"))
        );
    }

    #[test]
    fn core_atomicity_half_missing() {
        let crates = vec![
            make_crate("arcature", "core", true),
            make_crate("arcature-dx", "core", true),
        ];
        let plan = make_plan(vec![entry("arcature", "2026.1.5", 1)]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(
            findings
                .iter()
                .any(|f| f.message.contains("arcature-dx is not"))
        );
    }

    #[test]
    fn duplicate_order_reported() {
        let crates = vec![
            make_crate("crate-a", "a", true),
            make_crate("crate-b", "b", true),
        ];
        let plan = make_plan(vec![
            entry("crate-a", "2026.1.1", 1),
            entry("crate-b", "2026.1.1", 1),
        ]);
        let findings = validate_plan(&plan, SHA, &crates, &two_unit_graph());
        assert!(
            findings
                .iter()
                .any(|f| f.message.contains("duplicate order"))
        );
    }

    #[test]
    fn empty_plan_no_findings() {
        let plan = make_plan(vec![]);
        let findings = validate_plan(&plan, SHA, &[], &two_unit_graph());
        assert!(findings.is_empty());
    }
}