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