#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "Panicking is acceptable and often desired in tests."
)]
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use citum_schema::Style;
use citum_schema::reference::ReferenceClass;
use citum_schema_data::InputBibliography;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Pass,
SoftDegrade,
HardFail,
}
impl Outcome {
fn as_str(self) -> &'static str {
match self {
Outcome::Pass => "Pass",
Outcome::SoftDegrade => "SoftDegrade",
Outcome::HardFail => "HardFail",
}
}
}
struct Scenario {
id: &'static str,
category: &'static str,
declared: Outcome,
observed: fn() -> Outcome,
follow_up: &'static str,
}
use citum_schema::locale::{GeneralTerm, TermForm};
use citum_schema::reference::{
ClassExtension, CollectionType, ContributorRole, MonographComponentType, MonographType,
SerialComponentType,
};
use citum_schema::template::{DateForm, TemplateComponent};
fn parse_style(yaml: &str) -> Outcome {
match Style::from_yaml_str(yaml) {
Ok(style) => {
if style_has_unknowns(&style) {
Outcome::SoftDegrade
} else {
Outcome::Pass
}
}
Err(_) => Outcome::HardFail,
}
}
fn style_has_unknowns(style: &Style) -> bool {
if !citum_engine::api::collect_unknown_field_paths(style).is_empty() {
return true;
}
if let Some(citation) = &style.citation
&& let Some(template) = &citation.template
&& template_has_unknowns(template)
{
return true;
}
if let Some(bib) = &style.bibliography
&& let Some(template) = &bib.template
&& template_has_unknowns(template)
{
return true;
}
if let Some(templates) = &style.templates {
for template in templates.values() {
if template_has_unknowns(template) {
return true;
}
}
}
false
}
fn template_has_unknowns(components: &[TemplateComponent]) -> bool {
for component in components {
match component {
TemplateComponent::Term(t) => {
if matches!(t.term, GeneralTerm::Unknown(_)) {
return true;
}
if let Some(TermForm::Unknown(_)) = &t.form {
return true;
}
}
TemplateComponent::Contributor(c) => {
if matches!(
c.contributor,
citum_schema::template::ContributorRole::Unknown(_)
) {
return true;
}
}
TemplateComponent::Date(d) => {
if matches!(d.form, DateForm::Unknown(_)) {
return true;
}
}
TemplateComponent::Group(g) if template_has_unknowns(&g.group) => {
return true;
}
_ => {}
}
}
false
}
fn parse_bibliography(yaml: &str) -> Outcome {
match serde_yaml::from_str::<InputBibliography>(yaml) {
Ok(bibliography) => {
let has_unknown = bibliography.references.iter().any(|reference| {
if matches!(reference.class(), ReferenceClass::Unknown(_)) {
return true;
}
let ext_unknown = match reference.extension() {
ClassExtension::Monograph(r) => {
matches!(r.r#type, MonographType::Unknown(_))
|| !r.unknown_fields.is_empty()
}
ClassExtension::Collection(r) => {
matches!(r.r#type, CollectionType::Unknown(_))
|| !r.unknown_fields.is_empty()
}
ClassExtension::CollectionComponent(r) => {
matches!(r.r#type, MonographComponentType::Unknown(_))
|| !r.unknown_fields.is_empty()
}
ClassExtension::SerialComponent(r) => {
matches!(r.r#type, SerialComponentType::Unknown(_))
|| !r.unknown_fields.is_empty()
}
_ => false,
};
ext_unknown
|| reference
.all_contributor_entries()
.iter()
.any(|c| matches!(c.role, ContributorRole::Unknown(_)))
});
if has_unknown {
Outcome::SoftDegrade
} else {
Outcome::Pass
}
}
Err(_) => Outcome::HardFail,
}
}
const STYLE_HEAD: &str =
"version: \"0.51\"\ninfo:\n id: forward-compat-fixture\n title: Forward compat fixture\n";
fn case_attribute_enum_in_template() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - contributor: producer\n form: long\n"
);
parse_style(&yaml)
}
fn case_attribute_enum_in_data() -> Outcome {
let yaml = "references:\n - id: perf2026\n class: monograph\n type: dance-performance\n title: A Performance\n";
parse_bibliography(yaml)
}
fn case_discriminator_class() -> Outcome {
let yaml =
"references:\n - id: perf2026\n class: dance-performance\n title: A Performance\n";
parse_bibliography(yaml)
}
fn case_locale_form() -> Outcome {
let yaml =
format!("{STYLE_HEAD}bibliography:\n template:\n - term: page\n form: vocative\n");
parse_style(&yaml)
}
fn case_date_form() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - date: issued\n form: month-and-day\n"
);
parse_style(&yaml)
}
fn case_new_style_option_key() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}options:\n contributors:\n future-key: true\nbibliography:\n template:\n - contributor: author\n form: long\n"
);
parse_style(&yaml)
}
fn case_new_top_level_section() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}experiments:\n inline-author-disambiguation: true\nbibliography:\n template:\n - contributor: author\n form: long\n"
);
parse_style(&yaml)
}
fn case_new_reference_field() -> Outcome {
let yaml = "references:\n - id: smith2026\n class: monograph\n type: book\n audience: scholarly\n";
parse_bibliography(yaml)
}
fn case_new_locale_term_key() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - term: preprint-server\n form: long\n"
);
parse_style(&yaml)
}
fn case_custom_namespace() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - contributor: author\n form: long\n custom:\n publisher-x.house-format: true\n"
);
parse_style(&yaml)
}
fn case_version_only_signal() -> Outcome {
let yaml = "version: \"99.0\"\ninfo:\n id: forward-compat-fixture\n title: Forward compat fixture\nbibliography:\n template:\n - contributor: author\n form: long\n";
parse_style(yaml)
}
fn case_template_grammar() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - loop: authors\n body:\n - contributor: author\n form: long\n"
);
parse_style(&yaml)
}
fn case_template_required_field() -> Outcome {
let yaml = format!(
"{STYLE_HEAD}bibliography:\n template:\n - variable:\n not-a-real-shape: true\n"
);
parse_style(&yaml)
}
fn scenarios() -> Vec<Scenario> {
vec![
Scenario {
id: "01-attr-enum-template",
category: "Attribute enum in template",
declared: Outcome::SoftDegrade,
observed: case_attribute_enum_in_template,
follow_up: "csl26-ld6e tolerant-enum-deserializer",
},
Scenario {
id: "02-attr-enum-data",
category: "Attribute enum in reference data",
declared: Outcome::SoftDegrade,
observed: case_attribute_enum_in_data,
follow_up: "csl26-ld6e tolerant-enum-deserializer",
},
Scenario {
id: "02b-discriminator-class",
category: "Unknown InputReference class",
declared: Outcome::SoftDegrade,
observed: case_discriminator_class,
follow_up: "csl26-odgh inputreference-discriminator-design",
},
Scenario {
id: "03-locale-form",
category: "Locale TermForm value",
declared: Outcome::SoftDegrade,
observed: case_locale_form,
follow_up: "csl26-ld6e tolerant-enum-deserializer",
},
Scenario {
id: "04-date-form",
category: "DateForm value in template",
declared: Outcome::SoftDegrade,
observed: case_date_form,
follow_up: "csl26-ld6e tolerant-enum-deserializer",
},
Scenario {
id: "05-new-option-key",
category: "New key inside style option struct",
declared: Outcome::SoftDegrade,
observed: case_new_style_option_key,
follow_up: "csl26-0ksu capture-unknown-fields-wrapper",
},
Scenario {
id: "06-new-top-level-section",
category: "New top-level Style section",
declared: Outcome::SoftDegrade,
observed: case_new_top_level_section,
follow_up: "csl26-0ksu capture-unknown-fields-wrapper",
},
Scenario {
id: "07-new-reference-field",
category: "New optional field on reference type",
declared: Outcome::SoftDegrade,
observed: case_new_reference_field,
follow_up: "csl26-acfh reference-data-silent-acceptance",
},
Scenario {
id: "08-new-locale-term-key",
category: "New locale term key",
declared: Outcome::SoftDegrade,
observed: case_new_locale_term_key,
follow_up: "csl26-o1z5 tolerant-locale-lookup",
},
Scenario {
id: "09-custom-namespace",
category: "Namespaced custom.* metadata (control)",
declared: Outcome::Pass,
observed: case_custom_namespace,
follow_up: "—",
},
Scenario {
id: "10-version-only-signal",
category: "Style version bumped without other changes (control)",
declared: Outcome::Pass,
observed: case_version_only_signal,
follow_up: "—",
},
Scenario {
id: "11-template-grammar",
category: "New TemplateComponent variant (opt-out)",
declared: Outcome::HardFail,
observed: case_template_grammar,
follow_up: "—",
},
Scenario {
id: "12-template-required-field",
category: "Malformed template shape (opt-out)",
declared: Outcome::HardFail,
observed: case_template_required_field,
follow_up: "—",
},
]
}
fn render_report(rows: &[(String, Outcome, Outcome, String, String)]) -> String {
let mut out = String::new();
out.push_str("# Forward-compat gap snapshot\n");
out.push_str("#\n");
out.push_str("# Source: crates/citum-engine/tests/forward_compatibility.rs\n");
out.push_str("# Spec: docs/specs/FORWARD_COMPATIBILITY.md\n");
out.push_str("#\n");
out.push_str("# Format: id | category | declared | observed | gap? | follow-up\n");
out.push_str(
"# A row is a GAP when declared != observed. GAP rows roll up to follow-up beans.\n",
);
out.push_str("#\n\n");
for (id, declared, observed, category, follow_up) in rows {
let gap = if declared == observed { "ok " } else { "GAP" };
writeln!(
out,
"{:<28} | {:<55} | declared={:<11} observed={:<11} {} | {}",
id,
category,
declared.as_str(),
observed.as_str(),
gap,
follow_up,
)
.unwrap();
}
out
}
#[test]
fn forward_compat_snapshot_matches() {
let rows: Vec<_> = scenarios()
.into_iter()
.map(|s| {
let observed = (s.observed)();
(
s.id.to_string(),
s.declared,
observed,
s.category.to_string(),
s.follow_up.to_string(),
)
})
.collect();
let actual = render_report(&rows);
let snapshot_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("snapshots")
.join("forward_compat_gaps.snap");
if std::env::var_os("UPDATE_FORWARD_COMPAT_SNAPSHOT").is_some() {
fs::create_dir_all(snapshot_path.parent().unwrap()).unwrap();
fs::write(&snapshot_path, &actual).expect("write snapshot");
return;
}
let expected = fs::read_to_string(&snapshot_path).unwrap_or_else(|_| {
panic!(
"snapshot missing at {}. Run with UPDATE_FORWARD_COMPAT_SNAPSHOT=1 to create it.",
snapshot_path.display()
)
});
if expected != actual {
assert_eq!(
expected.trim_end(),
actual.trim_end(),
"Forward-compat snapshot drift detected. \
If this drift is intentional (an engine change closed or moved a gap), \
rerun with UPDATE_FORWARD_COMPAT_SNAPSHOT=1 and review the diff.",
);
}
}