Skip to main content

citum_engine/api/
warnings.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Input-compatibility warning scanners.
7//!
8//! Each function inspects already-loaded inputs (style, bibliography) for
9//! constructs the engine tolerated but cannot act on — unknown reference
10//! classes, fields captured by the forward-compat `unknown_fields`
11//! catch-all, and unknown enum variants — and reports them as structured
12//! [`Warning`]s. Adapters (CLI, WASM, FFI) present these; they must never
13//! re-derive their own checks.
14
15use crate::processor::Processor;
16use crate::reference::Bibliography;
17use citum_schema::locale::{GeneralTerm, TermForm};
18use citum_schema::reference::{
19    ClassExtension, CollectionType, ContributorRole as ReferenceRole, MonographComponentType,
20    MonographType, ReferenceClass, SerialComponentType,
21};
22use citum_schema::template::ContributorRole as TemplateRole;
23
24use super::{Warning, WarningLevel};
25
26/// Scan the bibliography for unknown reference classes and return compatibility warnings.
27pub fn unknown_reference_class_warnings(bibliography: &Bibliography) -> Vec<Warning> {
28    bibliography
29        .iter()
30        .filter_map(|(ref_id, reference)| {
31            let ReferenceClass::Unknown(class) = reference.class() else {
32                return None;
33            };
34            Some(Warning {
35                level: WarningLevel::Warning,
36                code: "unknown_reference_class".to_string(),
37                citation_id: None,
38                ref_id: Some(ref_id.clone()),
39                message: format!(
40                    "Reference '{ref_id}' uses unknown class '{class}'; rendering will use only fields this engine understands."
41                ),
42            })
43        })
44        .collect()
45}
46
47/// Scan the bibliography for fields captured by the forward-compat
48/// `unknown_fields` catch-all and return per-reference warnings.
49///
50/// Unknown-class references are skipped here; they are already reported by
51/// [`unknown_reference_class_warnings`].
52pub fn unknown_reference_field_warnings(bibliography: &Bibliography) -> Vec<Warning> {
53    bibliography
54        .iter()
55        .filter_map(|(ref_id, reference)| {
56            let unknown = reference.unknown_fields()?;
57            if unknown.is_empty() {
58                return None;
59            }
60            let keys: Vec<&str> = unknown.keys().map(String::as_str).collect();
61            Some(Warning {
62                level: WarningLevel::Warning,
63                code: "unknown_reference_field".to_string(),
64                citation_id: None,
65                ref_id: Some(ref_id.clone()),
66                message: format!(
67                    "Reference '{ref_id}' has unknown field(s): {}; these fields are ignored during rendering.",
68                    keys.join(", ")
69                ),
70            })
71        })
72        .collect()
73}
74
75/// Scan the style and bibliography for unknown enum variants and term keys.
76///
77/// Returns a list of structured compatibility warnings for encounter of
78/// unknown variants that were captured via the tolerant-enum mechanism.
79pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
80    let mut warnings = Vec::new();
81
82    // 1. Scan bibliography
83    for (ref_id, reference) in &processor.bibliography {
84        match reference.extension() {
85            ClassExtension::Monograph(r) => {
86                if let MonographType::Unknown(s) = &r.r#type {
87                    warnings.push(Warning {
88                        level: WarningLevel::Warning,
89                        code: "unknown_enum_variant".to_string(),
90                        citation_id: None,
91                        ref_id: Some(ref_id.clone()),
92                        message: format!("Reference '{ref_id}' uses unknown monograph type '{s}'; rendering will use default monograph formatting."),
93                    });
94                }
95            }
96            ClassExtension::Collection(r) => {
97                if let CollectionType::Unknown(s) = &r.r#type {
98                    warnings.push(Warning {
99                        level: WarningLevel::Warning,
100                        code: "unknown_enum_variant".to_string(),
101                        citation_id: None,
102                        ref_id: Some(ref_id.clone()),
103                        message: format!("Reference '{ref_id}' uses unknown collection type '{s}'; rendering will use default collection formatting."),
104                    });
105                }
106            }
107            ClassExtension::CollectionComponent(r) => {
108                if let MonographComponentType::Unknown(s) = &r.r#type {
109                    warnings.push(Warning {
110                        level: WarningLevel::Warning,
111                        code: "unknown_enum_variant".to_string(),
112                        citation_id: None,
113                        ref_id: Some(ref_id.clone()),
114                        message: format!("Reference '{ref_id}' uses unknown monograph component type '{s}'; rendering will use default chapter formatting."),
115                    });
116                }
117            }
118            ClassExtension::SerialComponent(r) => {
119                if let SerialComponentType::Unknown(s) = &r.r#type {
120                    warnings.push(Warning {
121                        level: WarningLevel::Warning,
122                        code: "unknown_enum_variant".to_string(),
123                        citation_id: None,
124                        ref_id: Some(ref_id.clone()),
125                        message: format!("Reference '{ref_id}' uses unknown serial component type '{s}'; rendering will use default article formatting."),
126                    });
127                }
128            }
129            _ => {}
130        }
131
132        for contributor in reference.all_contributor_entries() {
133            for role in contributor.roles.as_slice() {
134                if let ReferenceRole::Unknown(s) = role {
135                    warnings.push(Warning {
136                        level: WarningLevel::Warning,
137                        code: "unknown_enum_variant".to_string(),
138                        citation_id: None,
139                        ref_id: Some(ref_id.clone()),
140                        message: format!("Reference '{ref_id}' uses unknown contributor role '{s}'; this role may be ignored during rendering."),
141                    });
142                }
143            }
144        }
145    }
146
147    // 2. Scan Style
148    if let Some(templates) = &processor.style.templates {
149        for (name, template) in templates {
150            scan_template_for_unknowns(template, &format!("template '{name}'"), &mut warnings);
151        }
152    }
153    if let Some(citation) = &processor.style.citation {
154        scan_citation_spec_for_unknowns(citation, "citation layout", &mut warnings);
155    }
156    if let Some(bib) = &processor.style.bibliography {
157        if let Some(template) = &bib.template {
158            scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
159        }
160        if let Some(type_variants) = &bib.type_variants {
161            for variant in type_variants.values() {
162                if let Some(template) = variant.as_template() {
163                    scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
164                }
165            }
166        }
167        if let Some(locales) = &bib.locales {
168            for locale_spec in locales {
169                scan_template_for_unknowns(
170                    &locale_spec.template,
171                    "bibliography layout",
172                    &mut warnings,
173                );
174            }
175        }
176    }
177    scan_bibliography_config_sort_for_citation_number(processor, &mut warnings);
178
179    warnings
180}
181
182/// Warn when the bibliography's explicit config-level sort lists
183/// `citation-number` as a key. `Sort::group_sort` drops the key rather than
184/// mapping it, so it contributes nothing to bibliography ordering — a silent
185/// no-op the style author almost certainly did not intend. The
186/// `citation-number` *preset* (`SortEntry::Preset`) is exempt: it is the
187/// documented way to say "no bibliography sort" for numeric styles.
188fn scan_bibliography_config_sort_for_citation_number(
189    processor: &Processor,
190    warnings: &mut Vec<Warning>,
191) {
192    let Some(citum_schema::options::SortEntry::Explicit(sort)) = processor
193        .get_bibliography_config()
194        .processing
195        .as_ref()
196        .map(citum_schema::options::Processing::config)
197        .and_then(|config| config.sort)
198    else {
199        return;
200    };
201
202    let uses_citation_number = sort
203        .template
204        .iter()
205        .any(|spec| matches!(spec.key, citum_schema::options::SortKey::CitationNumber));
206
207    if uses_citation_number {
208        warnings.push(Warning {
209            level: WarningLevel::Warning,
210            code: "citation_number_sort_not_supported".to_string(),
211            citation_id: None,
212            ref_id: None,
213            message: "Style bibliography configuration lists 'citation-number' as an explicit \
214                      sort key; it is not supported and is ignored for bibliography ordering."
215                .to_string(),
216        });
217    }
218}
219
220/// Recursively scan a [`citum_schema::CitationSpec`] and its mode/position
221/// sub-specs (`integral`, `non-integral`, `subsequent`, `ibid`), plus its
222/// `type-variants` and per-locale templates, for unknown enum variants.
223///
224/// The top-level `unknown_enum_warnings` scan previously inspected only the
225/// main citation template, missing unknown terms/roles/date-forms nested in
226/// sub-specs, type-variants, and localized templates.
227fn scan_citation_spec_for_unknowns(
228    spec: &citum_schema::CitationSpec,
229    location: &str,
230    warnings: &mut Vec<Warning>,
231) {
232    if let Some(template) = &spec.template {
233        scan_template_for_unknowns(template, location, warnings);
234    }
235    if let Some(type_variants) = &spec.type_variants {
236        for variant in type_variants.values() {
237            if let Some(template) = variant.as_template() {
238                scan_template_for_unknowns(template, location, warnings);
239            }
240        }
241    }
242    if let Some(locales) = &spec.locales {
243        for locale_spec in locales {
244            scan_template_for_unknowns(&locale_spec.template, location, warnings);
245        }
246    }
247
248    if let Some(child) = &spec.integral {
249        scan_citation_spec_for_unknowns(child, &format!("{location} (integral)"), warnings);
250    }
251    if let Some(child) = &spec.non_integral {
252        scan_citation_spec_for_unknowns(child, &format!("{location} (non-integral)"), warnings);
253    }
254    if let Some(child) = &spec.subsequent {
255        scan_citation_spec_for_unknowns(child, &format!("{location} (subsequent)"), warnings);
256    }
257    if let Some(child) = &spec.ibid {
258        scan_citation_spec_for_unknowns(child, &format!("{location} (ibid)"), warnings);
259    }
260}
261
262fn scan_template_for_unknowns(
263    components: &[citum_schema::template::TemplateComponent],
264    location: &str,
265    warnings: &mut Vec<Warning>,
266) {
267    use citum_schema::template::TemplateComponent;
268    for component in components {
269        match component {
270            TemplateComponent::Term(t) => {
271                if let GeneralTerm::Unknown(s) = &t.term {
272                    warnings.push(Warning {
273                        level: WarningLevel::Warning,
274                        code: "unknown_enum_variant".to_string(),
275                        citation_id: None,
276                        ref_id: None,
277                        message: format!("Style {location} uses unknown locale term key '{s}'; this term may render as empty."),
278                    });
279                }
280                if let Some(TermForm::Unknown(s)) = &t.form {
281                    warnings.push(Warning {
282                        level: WarningLevel::Warning,
283                        code: "unknown_enum_variant".to_string(),
284                        citation_id: None,
285                        ref_id: None,
286                        message: format!("Style {location} uses unknown term form '{s}'; falling back to long form."),
287                    });
288                }
289            }
290            TemplateComponent::Contributor(c) => {
291                for role in c.contributor.as_slice() {
292                    if let TemplateRole::Unknown(s) = role {
293                        warnings.push(Warning {
294                            level: WarningLevel::Warning,
295                            code: "unknown_enum_variant".to_string(),
296                            citation_id: None,
297                            ref_id: None,
298                            message: format!("Style {location} uses unknown contributor role '{s}'; this role may be ignored."),
299                        });
300                    }
301                }
302                if let Some(label) = &c.label {
303                    let term = label.term.as_str();
304                    if !crate::values::contributor::labels::RECOGNIZED_LABEL_TERMS.contains(&term) {
305                        warnings.push(Warning {
306                            level: WarningLevel::Warning,
307                            code: "unknown_role_label_term".to_string(),
308                            citation_id: None,
309                            ref_id: None,
310                            message: format!("Style {location} uses unrecognized role-label term '{term}'; falling back to the contributor's own role term instead of the requested one."),
311                        });
312                    }
313                }
314            }
315            TemplateComponent::Date(d) => {
316                if let citum_schema::template::DateForm::Unknown(s) = &d.form {
317                    warnings.push(Warning {
318                        level: WarningLevel::Warning,
319                        code: "unknown_enum_variant".to_string(),
320                        citation_id: None,
321                        ref_id: None,
322                        message: format!("Style {location} uses unknown date form '{s}'; falling back to year only."),
323                    });
324                }
325            }
326            TemplateComponent::Group(g) => {
327                scan_template_for_unknowns(&g.group, location, warnings);
328            }
329            _ => {}
330        }
331    }
332}
333
334#[cfg(test)]
335#[allow(clippy::unwrap_used, reason = "tests")]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn unknown_enum_warnings_reports_unknown_term_in_integral_sub_spec() {
341        let yaml = "info:\n  title: Test\ncitation:\n  integral:\n    template:\n      - term: not-a-real-term\n";
342        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
343        let processor = Processor::new(style, Bibliography::new());
344
345        let warnings = unknown_enum_warnings(&processor);
346        assert!(
347            warnings
348                .iter()
349                .any(|w| w.message.contains("not-a-real-term") && w.message.contains("(integral)")),
350            "expected a warning for the unknown term in citation.integral.template, got: {warnings:?}"
351        );
352    }
353
354    #[test]
355    fn unknown_enum_warnings_reports_unknown_role_label_term() {
356        let yaml = "info:\n  title: Test\nbibliography:\n  template:\n    - contributor: editor\n      form: long\n      label: {term: not-a-real-role}\n";
357        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
358        let processor = Processor::new(style, Bibliography::new());
359
360        let warnings = unknown_enum_warnings(&processor);
361        assert!(
362            warnings
363                .iter()
364                .any(|w| w.code == "unknown_role_label_term"
365                    && w.message.contains("not-a-real-role")),
366            "expected a warning for the unrecognized role-label term, got: {warnings:?}"
367        );
368    }
369
370    #[test]
371    fn unknown_enum_warnings_does_not_flag_recognized_role_label_terms() {
372        let yaml = "info:\n  title: Test\nbibliography:\n  template:\n    - contributor: editor\n      form: long\n      label: {term: editor}\n";
373        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
374        let processor = Processor::new(style, Bibliography::new());
375
376        let warnings = unknown_enum_warnings(&processor);
377        assert!(
378            !warnings.iter().any(|w| w.code == "unknown_role_label_term"),
379            "did not expect a warning for a recognized role-label term, got: {warnings:?}"
380        );
381    }
382
383    #[test]
384    fn unknown_enum_warnings_reports_unknown_term_in_type_variants() {
385        let yaml = "info:\n  title: Test\ncitation:\n  type-variants:\n    book:\n      - term: not-a-real-term-2\n";
386        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
387        let processor = Processor::new(style, Bibliography::new());
388
389        let warnings = unknown_enum_warnings(&processor);
390        assert!(
391            warnings
392                .iter()
393                .any(|w| w.message.contains("not-a-real-term-2")),
394            "expected a warning for the unknown term in citation.type-variants, got: {warnings:?}"
395        );
396    }
397}