use std::collections::{BTreeMap, BTreeSet};
use super::schema::{ChangeFragmentFile, ChangeKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ChangeReport {
pub changed_units: BTreeMap<String, ChangeKind>,
pub entry_count: usize,
}
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;
if entry.unit.is_empty() {
diagnostics.push(format!("{}: entry has an empty `unit`", fragment.file_name));
continue;
}
if !known_units.contains(&entry.unit) {
diagnostics.push(format!(
"{}: unknown release unit `{}` (not found in workspace metadata)",
fragment.file_name, entry.unit
));
continue;
}
if entry.summary.is_empty() {
diagnostics.push(format!(
"{}: entry for unit `{}` has an empty `summary`",
fragment.file_name, entry.unit
));
continue;
}
match unit_kinds.get(&entry.unit) {
None => {
unit_kinds.insert(entry.unit.clone(), entry.kind);
}
Some(existing) => {
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
));
}
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");
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");
assert!(errors[0].contains("a-file.toml"));
assert!(errors[1].contains("z-file.toml"));
}
}