use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum ChangeClass {
OutputSchema,
GovernedData,
FormulaLogic,
InputSchema,
CapabilityContract,
Assumption,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
Drift,
Redefinition,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct OutputMeta {
pub meaning: Option<String>,
pub unit: Option<String>,
pub provenance: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct OutputDelta {
pub region: String,
pub change_class: ChangeClass,
pub old: OutputMeta,
pub new: OutputMeta,
pub severity: Severity,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct VersionChangelog {
pub from_version: String,
pub to_version: String,
pub deltas: Vec<OutputDelta>,
pub summary: String,
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_changelog() -> VersionChangelog {
VersionChangelog {
from_version: "1.0.0".to_string(),
to_version: "1.1.0".to_string(),
deltas: vec![
OutputDelta {
region: "7_Quote!C11".to_string(),
change_class: ChangeClass::GovernedData,
old: OutputMeta {
meaning: Some("supply total".to_string()),
unit: Some("GBP".to_string()),
provenance: Some("colour+guide".to_string()),
},
new: OutputMeta {
meaning: Some("supply total".to_string()),
unit: Some("GBP".to_string()),
provenance: Some("colour+guide".to_string()),
},
severity: Severity::Drift,
},
OutputDelta {
region: "7_Quote!C12".to_string(),
change_class: ChangeClass::OutputSchema,
old: OutputMeta {
meaning: Some("install total".to_string()),
unit: Some("GBP".to_string()),
provenance: None,
},
new: OutputMeta {
meaning: Some("install total (inc VAT)".to_string()),
unit: Some("GBP".to_string()),
provenance: Some("colour+guide".to_string()),
},
severity: Severity::Redefinition,
},
],
summary: "1 drift, 1 redefinition".to_string(),
}
}
#[test]
fn version_changelog_round_trips() {
let original = sample_changelog();
let json = serde_json::to_string_pretty(&original).expect("serialize");
let restored: VersionChangelog = serde_json::from_str(&json).expect("deserialize");
assert_eq!(original, restored);
}
#[test]
fn output_delta_deserializes_from_fixture() {
let fixture = r#"{
"region": "7_Quote!C11",
"change_class": "governed-data",
"old": { "meaning": "supply total", "unit": "GBP", "provenance": "colour+guide" },
"new": { "meaning": "supply total", "unit": "GBP", "provenance": "colour+guide" },
"severity": "drift"
}"#;
let delta: OutputDelta = serde_json::from_str(fixture).expect("deserialize fixture");
assert_eq!(delta.region, "7_Quote!C11");
assert_eq!(delta.change_class, ChangeClass::GovernedData);
assert_eq!(delta.severity, Severity::Drift);
}
#[test]
fn severity_has_exactly_two_variants() {
assert_eq!(
serde_json::to_string(&Severity::Drift).expect("serialize drift"),
"\"drift\""
);
assert_eq!(
serde_json::to_string(&Severity::Redefinition).expect("serialize redefinition"),
"\"redefinition\""
);
for variant in [Severity::Drift, Severity::Redefinition] {
let json = serde_json::to_string(&variant).expect("serialize");
let restored: Severity = serde_json::from_str(&json).expect("deserialize");
assert_eq!(variant, restored);
}
}
#[test]
fn change_class_has_exactly_six_variants() {
let expected = [
(ChangeClass::OutputSchema, "\"output-schema\""),
(ChangeClass::GovernedData, "\"governed-data\""),
(ChangeClass::FormulaLogic, "\"formula-logic\""),
(ChangeClass::InputSchema, "\"input-schema\""),
(ChangeClass::CapabilityContract, "\"capability-contract\""),
(ChangeClass::Assumption, "\"assumption\""),
];
assert_eq!(expected.len(), 6);
for (variant, tag) in expected {
assert_eq!(
serde_json::to_string(&variant).expect("serialize"),
tag,
"wire tag for {variant:?}"
);
let restored: ChangeClass =
serde_json::from_str(tag).expect("deserialize from stable tag");
assert_eq!(variant, restored);
}
}
#[test]
fn unknown_change_class_tag_is_rejected() {
let forged = "\"super-admin-bypass\"";
let result: Result<ChangeClass, _> = serde_json::from_str(forged);
assert!(result.is_err(), "unknown ChangeClass tag must be rejected");
}
}