arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Change-fragment validation (ADR-0005 invariant 8).
//!
//! [`validate_fragments`] checks parsed fragment files against the known
//! set of release units (from metadata discovery). It rejects unknown
//! units, empty units, and contradictory entries (the same unit declared
//! both `compatible` and `breaking` across fragment files in the same
//! pending set). Diagnostics are collected in full and sorted
//! deterministically.

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

use super::schema::{ChangeFragmentFile, ChangeKind};

/// A successful validation report.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangeReport {
    /// unit name -> highest change kind across all entries for that unit.
    pub changed_units: BTreeMap<String, ChangeKind>,
    /// Total number of fragment entries parsed.
    pub entry_count: usize,
}

/// Validate parsed change fragments against the known release units.
///
/// `known_units` is the set of release-unit names discovered from cargo
/// metadata (the same set the metadata validator produced). Returns
/// `Ok(report)` when all entries are consistent, or `Err(diagnostics)`
/// with the complete, deterministically-ordered list of findings.
pub(crate) fn validate_fragments(
    fragments: &[ChangeFragmentFile],
    known_units: &BTreeSet<String>,
) -> Result<ChangeReport, Vec<String>> {
    let mut diagnostics = Vec::new();
    let mut unit_kinds: BTreeMap<String, ChangeKind> = BTreeMap::new();
    let mut entry_count = 0usize;

    for fragment in fragments {
        for entry in &fragment.entries {
            entry_count += 1;

            // Reject empty unit names.
            if entry.unit.is_empty() {
                diagnostics.push(format!("{}: entry has an empty `unit`", fragment.file_name));
                continue;
            }

            // Reject unknown units.
            if !known_units.contains(&entry.unit) {
                diagnostics.push(format!(
                    "{}: unknown release unit `{}` (not found in workspace metadata)",
                    fragment.file_name, entry.unit
                ));
                continue;
            }

            // Reject empty summaries.
            if entry.summary.is_empty() {
                diagnostics.push(format!(
                    "{}: entry for unit `{}` has an empty `summary`",
                    fragment.file_name, entry.unit
                ));
                continue;
            }

            // Merge by highest severity; detect contradictions.
            match unit_kinds.get(&entry.unit) {
                None => {
                    unit_kinds.insert(entry.unit.clone(), entry.kind);
                }
                Some(existing) => {
                    // Contradiction: compatible + breaking in the same set.
                    if (*existing == ChangeKind::Compatible && entry.kind == ChangeKind::Breaking)
                        || (*existing == ChangeKind::Breaking
                            && entry.kind == ChangeKind::Compatible)
                    {
                        diagnostics.push(format!(
                            "contradictory change kinds for unit `{}`: \
                             declared both `compatible` and `breaking` across fragment files",
                            entry.unit
                        ));
                    }
                    // Keep the highest-severity kind.
                    if entry.kind > *existing {
                        unit_kinds.insert(entry.unit.clone(), entry.kind);
                    }
                }
            }
        }
    }

    if diagnostics.is_empty() {
        Ok(ChangeReport {
            changed_units: unit_kinds,
            entry_count,
        })
    } else {
        diagnostics.sort();
        Err(diagnostics)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::change::schema::ChangeEntry;
    use std::collections::BTreeSet;

    fn known_units() -> BTreeSet<String> {
        ["arcature-auth", "arcature-db", "core", "arcature-cli"]
            .into_iter()
            .map(String::from)
            .collect()
    }

    fn fragment(name: &str, entries: Vec<ChangeEntry>) -> ChangeFragmentFile {
        ChangeFragmentFile {
            file_name: name.to_string(),
            entries,
        }
    }

    fn entry(unit: &str, kind: ChangeKind, summary: &str) -> ChangeEntry {
        ChangeEntry {
            unit: unit.to_string(),
            kind,
            summary: summary.to_string(),
        }
    }

    #[test]
    fn empty_fragments_validate_ok() {
        let report = validate_fragments(&[], &known_units()).expect("empty is ok");
        assert!(report.changed_units.is_empty());
        assert_eq!(report.entry_count, 0);
    }

    #[test]
    fn single_compatible_change_validates() {
        let fragments = vec![fragment(
            "71-auth-fix.toml",
            vec![entry(
                "arcature-auth",
                ChangeKind::Compatible,
                "Fix session refresh",
            )],
        )];
        let report = validate_fragments(&fragments, &known_units()).expect("valid");
        assert_eq!(report.entry_count, 1);
        assert_eq!(
            report.changed_units.get("arcature-auth"),
            Some(&ChangeKind::Compatible)
        );
    }

    #[test]
    fn multiple_units_in_one_file_validate() {
        let fragments = vec![fragment(
            "72-multi.toml",
            vec![
                entry("arcature-auth", ChangeKind::Compatible, "Fix A"),
                entry("arcature-db", ChangeKind::Breaking, "Break B"),
            ],
        )];
        let report = validate_fragments(&fragments, &known_units()).expect("valid");
        assert_eq!(report.entry_count, 2);
        assert_eq!(report.changed_units.len(), 2);
    }

    #[test]
    fn unknown_unit_is_rejected() {
        let fragments = vec![fragment(
            "bad.toml",
            vec![entry("arcature-unknown", ChangeKind::Compatible, "x")],
        )];
        let errors = validate_fragments(&fragments, &known_units()).expect_err("unknown unit");
        assert!(errors[0].contains("unknown release unit"));
        assert!(errors[0].contains("arcature-unknown"));
    }

    #[test]
    fn empty_unit_is_rejected() {
        let fragments = vec![fragment(
            "bad.toml",
            vec![entry("", ChangeKind::Compatible, "x")],
        )];
        let errors = validate_fragments(&fragments, &known_units()).expect_err("empty unit");
        assert!(errors[0].contains("empty `unit`"));
    }

    #[test]
    fn empty_summary_is_rejected() {
        let fragments = vec![fragment(
            "bad.toml",
            vec![entry("arcature-auth", ChangeKind::Compatible, "")],
        )];
        let errors = validate_fragments(&fragments, &known_units()).expect_err("empty summary");
        assert!(errors[0].contains("empty `summary`"));
    }

    #[test]
    fn contradictory_kinds_across_files_are_rejected() {
        let fragments = vec![
            fragment(
                "71-a.toml",
                vec![entry("arcature-auth", ChangeKind::Compatible, "Fix A")],
            ),
            fragment(
                "72-b.toml",
                vec![entry("arcature-auth", ChangeKind::Breaking, "Break B")],
            ),
        ];
        let errors = validate_fragments(&fragments, &known_units()).expect_err("contradiction");
        assert!(errors.iter().any(|e| e.contains("contradictory")));
    }

    #[test]
    fn same_kind_across_files_is_ok() {
        let fragments = vec![
            fragment(
                "71-a.toml",
                vec![entry("arcature-auth", ChangeKind::Compatible, "Fix A")],
            ),
            fragment(
                "72-b.toml",
                vec![entry("arcature-auth", ChangeKind::Compatible, "Fix B")],
            ),
        ];
        let report = validate_fragments(&fragments, &known_units()).expect("same kind ok");
        assert_eq!(
            report.changed_units.get("arcature-auth"),
            Some(&ChangeKind::Compatible)
        );
        assert_eq!(report.entry_count, 2);
    }

    #[test]
    fn none_kind_does_not_contradict_compatible() {
        let fragments = vec![
            fragment(
                "71-a.toml",
                vec![entry("arcature-auth", ChangeKind::None, "internal")],
            ),
            fragment(
                "72-b.toml",
                vec![entry("arcature-auth", ChangeKind::Compatible, "fix")],
            ),
        ];
        let report = validate_fragments(&fragments, &known_units()).expect("none+compatible ok");
        // Highest severity wins.
        assert_eq!(
            report.changed_units.get("arcature-auth"),
            Some(&ChangeKind::Compatible)
        );
    }

    #[test]
    fn breaking_takes_precedence_over_none() {
        let fragments = vec![fragment(
            "71.toml",
            vec![
                entry("arcature-auth", ChangeKind::None, "internal"),
                entry("arcature-auth", ChangeKind::Breaking, "break"),
            ],
        )];
        let report = validate_fragments(&fragments, &known_units()).expect("valid");
        assert_eq!(
            report.changed_units.get("arcature-auth"),
            Some(&ChangeKind::Breaking)
        );
    }

    #[test]
    fn diagnostics_are_sorted() {
        let fragments = vec![
            fragment(
                "z-file.toml",
                vec![entry("arcature-unknown", ChangeKind::Compatible, "z")],
            ),
            fragment("a-file.toml", vec![entry("", ChangeKind::Compatible, "a")]),
        ];
        let errors = validate_fragments(&fragments, &known_units()).expect_err("errors");
        // Sorted: empty-unit diagnostic comes before unknown-unit
        assert!(errors[0].contains("a-file.toml"));
        assert!(errors[1].contains("z-file.toml"));
    }
}