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/// Warn when the bibliography config declares a numeric or alphabetic
74/// reference-marker mode with no `label-wrap` and no `label-separator`,
75/// which renders the marker flush against the entry body (`1Smith`, not
76/// `1. Smith` or `[1] Smith`).
77///
78/// `label-separator` defaults to empty by design, and several shipped
79/// styles depend on a genuinely flush marker — `royal-society-of-chemistry`
80/// documents `second-field-align="flush"` from its CSL source, and
81/// `gb-t-7714-2025-base` documents a bracketed-but-flush marker — so this is
82/// advisory, not an error: it surfaces a configuration shape that is easy to
83/// author unintentionally without asserting it is wrong. `author-date` mode
84/// is exempt: it never produces a bibliography marker (see `marker.rs`), so
85/// wrap and separator are inert for it. See `docs/specs/REFERENCE_MARKERS.md`.
86pub fn bibliography_label_missing_separator_warnings(processor: &Processor) -> Vec<Warning> {
87    let config = processor.get_bibliography_options();
88    let produces_marker = matches!(
89        config.label_mode,
90        Some(
91            citum_schema::options::BibliographyLabelMode::Numeric
92                | citum_schema::options::BibliographyLabelMode::Alphabetic
93        )
94    );
95    if !produces_marker || config.label_wrap.is_some() || config.label_separator.is_some() {
96        return Vec::new();
97    }
98
99    vec![Warning {
100        level: WarningLevel::Warning,
101        code: "bibliography_label_no_separator".to_string(),
102        citation_id: None,
103        ref_id: None,
104        message: "Bibliography configuration declares a numeric or alphabetic label-mode with \
105                  no label-wrap and no label-separator; the reference marker will render flush \
106                  against the entry body (e.g. '1Smith' rather than '1. Smith'). If this is \
107                  intentional (flush second-field-align), no action is needed."
108            .to_string(),
109    }]
110}
111
112/// Scan the bibliography for unknown reference classes and return compatibility warnings.
113pub fn unknown_reference_class_warnings(bibliography: &Bibliography) -> Vec<Warning> {
114    bibliography
115        .iter()
116        .filter_map(|(ref_id, reference)| {
117            let ReferenceClass::Unknown(class) = reference.class() else {
118                return None;
119            };
120            Some(Warning {
121                level: WarningLevel::Warning,
122                code: "unknown_reference_class".to_string(),
123                citation_id: None,
124                ref_id: Some(ref_id.clone()),
125                message: format!(
126                    "Reference '{ref_id}' uses unknown class '{class}'; rendering will use only fields this engine understands."
127                ),
128            })
129        })
130        .collect()
131}
132
133/// Scan the bibliography for fields captured by the forward-compat
134/// `unknown_fields` catch-all and return per-reference warnings.
135///
136/// Unknown-class references are skipped here; they are already reported by
137/// [`unknown_reference_class_warnings`].
138pub fn unknown_reference_field_warnings(bibliography: &Bibliography) -> Vec<Warning> {
139    bibliography
140        .iter()
141        .filter_map(|(ref_id, reference)| {
142            let unknown = reference.unknown_fields()?;
143            if unknown.is_empty() {
144                return None;
145            }
146            let keys: Vec<&str> = unknown.keys().map(String::as_str).collect();
147            Some(Warning {
148                level: WarningLevel::Warning,
149                code: "unknown_reference_field".to_string(),
150                citation_id: None,
151                ref_id: Some(ref_id.clone()),
152                message: format!(
153                    "Reference '{ref_id}' has unknown field(s): {}; these fields are ignored during rendering.",
154                    keys.join(", ")
155                ),
156            })
157        })
158        .collect()
159}
160
161/// Scan the style and bibliography for unknown enum variants and term keys.
162///
163/// Returns a list of structured compatibility warnings for encounter of
164/// unknown variants that were captured via the tolerant-enum mechanism.
165pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
166    let mut warnings = Vec::new();
167
168    // 1. Scan bibliography
169    for (ref_id, reference) in &processor.bibliography {
170        match reference.extension() {
171            ClassExtension::Monograph(r) => {
172                if let MonographType::Unknown(s) = &r.r#type {
173                    warnings.push(Warning {
174                        level: WarningLevel::Warning,
175                        code: "unknown_enum_variant".to_string(),
176                        citation_id: None,
177                        ref_id: Some(ref_id.clone()),
178                        message: format!("Reference '{ref_id}' uses unknown monograph type '{s}'; rendering will use default monograph formatting."),
179                    });
180                }
181            }
182            ClassExtension::Collection(r) => {
183                if let CollectionType::Unknown(s) = &r.r#type {
184                    warnings.push(Warning {
185                        level: WarningLevel::Warning,
186                        code: "unknown_enum_variant".to_string(),
187                        citation_id: None,
188                        ref_id: Some(ref_id.clone()),
189                        message: format!("Reference '{ref_id}' uses unknown collection type '{s}'; rendering will use default collection formatting."),
190                    });
191                }
192            }
193            ClassExtension::CollectionComponent(r) => {
194                if let MonographComponentType::Unknown(s) = &r.r#type {
195                    warnings.push(Warning {
196                        level: WarningLevel::Warning,
197                        code: "unknown_enum_variant".to_string(),
198                        citation_id: None,
199                        ref_id: Some(ref_id.clone()),
200                        message: format!("Reference '{ref_id}' uses unknown monograph component type '{s}'; rendering will use default chapter formatting."),
201                    });
202                }
203            }
204            ClassExtension::SerialComponent(r) => {
205                if let SerialComponentType::Unknown(s) = &r.r#type {
206                    warnings.push(Warning {
207                        level: WarningLevel::Warning,
208                        code: "unknown_enum_variant".to_string(),
209                        citation_id: None,
210                        ref_id: Some(ref_id.clone()),
211                        message: format!("Reference '{ref_id}' uses unknown serial component type '{s}'; rendering will use default article formatting."),
212                    });
213                }
214            }
215            _ => {}
216        }
217
218        for contributor in reference.all_contributor_entries() {
219            for role in contributor.roles.as_slice() {
220                if let ReferenceRole::Unknown(s) = role {
221                    warnings.push(Warning {
222                        level: WarningLevel::Warning,
223                        code: "unknown_enum_variant".to_string(),
224                        citation_id: None,
225                        ref_id: Some(ref_id.clone()),
226                        message: format!("Reference '{ref_id}' uses unknown contributor role '{s}'; this role may be ignored during rendering."),
227                    });
228                }
229            }
230        }
231    }
232
233    // 2. Scan Style
234    if let Some(templates) = &processor.style.templates {
235        for (name, template) in templates {
236            scan_template_for_unknowns(template, &format!("template '{name}'"), &mut warnings);
237        }
238    }
239    if let Some(citation) = &processor.style.citation {
240        scan_citation_spec_for_unknowns(citation, "citation layout", &mut warnings);
241    }
242    if let Some(bib) = &processor.style.bibliography {
243        if let Some(template) = &bib.template {
244            scan_template_variant_for_unknowns(template, "bibliography layout", &mut warnings);
245        }
246        if let Some(type_variants) = &bib.type_variants {
247            for variant in type_variants.values() {
248                if let Some(template) = variant.as_template() {
249                    scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
250                }
251            }
252        }
253        if let Some(locales) = &bib.locales {
254            for locale_spec in locales {
255                scan_template_for_unknowns(
256                    &locale_spec.template,
257                    "bibliography layout",
258                    &mut warnings,
259                );
260            }
261        }
262    }
263    scan_bibliography_config_sort_for_citation_number(processor, &mut warnings);
264
265    warnings
266}
267
268/// Warn when the bibliography's explicit config-level sort lists
269/// `citation-number` as a key. `Sort::group_sort` drops the key rather than
270/// mapping it, so it contributes nothing to bibliography ordering — a silent
271/// no-op the style author almost certainly did not intend. The
272/// `citation-number` *preset* (`SortEntry::Preset`) is exempt: it is the
273/// documented way to say "no bibliography sort" for numeric styles.
274fn scan_bibliography_config_sort_for_citation_number(
275    processor: &Processor,
276    warnings: &mut Vec<Warning>,
277) {
278    let Some(citum_schema::options::SortEntry::Explicit(sort)) = processor
279        .get_bibliography_config()
280        .processing
281        .as_ref()
282        .map(citum_schema::options::Processing::config)
283        .and_then(|config| config.sort)
284    else {
285        return;
286    };
287
288    let uses_citation_number = sort
289        .template
290        .iter()
291        .any(|spec| matches!(spec.key, citum_schema::options::SortKey::CitationNumber));
292
293    if uses_citation_number {
294        warnings.push(Warning {
295            level: WarningLevel::Warning,
296            code: "citation_number_sort_not_supported".to_string(),
297            citation_id: None,
298            ref_id: None,
299            message: "Style bibliography configuration lists 'citation-number' as an explicit \
300                      sort key; it is not supported and is ignored for bibliography ordering."
301                .to_string(),
302        });
303    }
304}
305
306/// Recursively scan a [`citum_schema::CitationSpec`] and its mode/position
307/// sub-specs (`integral`, `non-integral`, `subsequent`, `ibid`), plus its
308/// `type-variants` and per-locale templates, for unknown enum variants.
309///
310/// The top-level `unknown_enum_warnings` scan previously inspected only the
311/// main citation template, missing unknown terms/roles/date-forms nested in
312/// sub-specs, type-variants, and localized templates.
313fn scan_citation_spec_for_unknowns(
314    spec: &citum_schema::CitationSpec,
315    location: &str,
316    warnings: &mut Vec<Warning>,
317) {
318    if let Some(template) = &spec.template {
319        scan_template_variant_for_unknowns(template, location, warnings);
320    }
321    if let Some(type_variants) = &spec.type_variants {
322        for variant in type_variants.values() {
323            if let Some(template) = variant.as_template() {
324                scan_template_for_unknowns(template, location, warnings);
325            }
326        }
327    }
328    if let Some(locales) = &spec.locales {
329        for locale_spec in locales {
330            scan_template_for_unknowns(&locale_spec.template, location, warnings);
331        }
332    }
333
334    if let Some(child) = &spec.integral {
335        scan_citation_spec_for_unknowns(child, &format!("{location} (integral)"), warnings);
336    }
337    if let Some(child) = &spec.non_integral {
338        scan_citation_spec_for_unknowns(child, &format!("{location} (non-integral)"), warnings);
339    }
340    if let Some(child) = &spec.subsequent {
341        scan_citation_spec_for_unknowns(child, &format!("{location} (subsequent)"), warnings);
342    }
343    if let Some(child) = &spec.ibid {
344        scan_citation_spec_for_unknowns(child, &format!("{location} (ibid)"), warnings);
345    }
346}
347
348fn scan_template_variant_for_unknowns(
349    variant: &citum_schema::template::TemplateVariant,
350    location: &str,
351    warnings: &mut Vec<Warning>,
352) {
353    match variant {
354        citum_schema::template::TemplateVariant::Full(template) => {
355            scan_template_for_unknowns(template, location, warnings);
356        }
357        citum_schema::template::TemplateVariant::Diff(diff) => {
358            for add in &diff.add {
359                scan_template_for_unknowns(
360                    std::slice::from_ref(&add.component),
361                    location,
362                    warnings,
363                );
364            }
365        }
366    }
367}
368
369fn scan_template_for_unknowns(
370    components: &[citum_schema::template::TemplateComponent],
371    location: &str,
372    warnings: &mut Vec<Warning>,
373) {
374    use citum_schema::template::TemplateComponent;
375    for component in components {
376        match component {
377            TemplateComponent::Term(t) => {
378                if let GeneralTerm::Unknown(s) = &t.term {
379                    warnings.push(Warning {
380                        level: WarningLevel::Warning,
381                        code: "unknown_enum_variant".to_string(),
382                        citation_id: None,
383                        ref_id: None,
384                        message: format!("Style {location} uses unknown locale term key '{s}'; this term may render as empty."),
385                    });
386                }
387                if let Some(TermForm::Unknown(s)) = &t.form {
388                    warnings.push(Warning {
389                        level: WarningLevel::Warning,
390                        code: "unknown_enum_variant".to_string(),
391                        citation_id: None,
392                        ref_id: None,
393                        message: format!("Style {location} uses unknown term form '{s}'; falling back to long form."),
394                    });
395                }
396            }
397            TemplateComponent::Contributor(c) => {
398                for role in c.contributor.as_slice() {
399                    if let TemplateRole::Unknown(s) = role {
400                        warnings.push(Warning {
401                            level: WarningLevel::Warning,
402                            code: "unknown_enum_variant".to_string(),
403                            citation_id: None,
404                            ref_id: None,
405                            message: format!("Style {location} uses unknown contributor role '{s}'; this role may be ignored."),
406                        });
407                    }
408                }
409                if let Some(label) = &c.label {
410                    let term = label.term.as_str();
411                    if !crate::values::contributor::labels::RECOGNIZED_LABEL_TERMS.contains(&term) {
412                        warnings.push(Warning {
413                            level: WarningLevel::Warning,
414                            code: "unknown_role_label_term".to_string(),
415                            citation_id: None,
416                            ref_id: None,
417                            message: format!("Style {location} uses unrecognized role-label term '{term}'; falling back to the contributor's own role term instead of the requested one."),
418                        });
419                    }
420                }
421            }
422            TemplateComponent::Date(d) => {
423                if let citum_schema::template::DateForm::Unknown(s) = &d.form {
424                    warnings.push(Warning {
425                        level: WarningLevel::Warning,
426                        code: "unknown_enum_variant".to_string(),
427                        citation_id: None,
428                        ref_id: None,
429                        message: format!("Style {location} uses unknown date form '{s}'; falling back to year only."),
430                    });
431                }
432            }
433            TemplateComponent::Group(g) => {
434                scan_template_for_unknowns(&g.group, location, warnings);
435            }
436            _ => {}
437        }
438    }
439}
440
441#[cfg(test)]
442#[allow(clippy::unwrap_used, reason = "tests")]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn bibliography_label_missing_separator_warnings_reports_bare_numeric_label_mode() {
448        let yaml = "info:\n  title: Test\nbibliography:\n  options:\n    label-mode: numeric\n";
449        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
450        let processor = Processor::new(style, Bibliography::new());
451
452        let warnings = bibliography_label_missing_separator_warnings(&processor);
453        assert!(
454            warnings
455                .iter()
456                .any(|w| w.code == "bibliography_label_no_separator"),
457            "expected a warning for label-mode with no wrap and no separator, got: {warnings:?}"
458        );
459    }
460
461    #[test]
462    fn bibliography_label_missing_separator_warnings_silent_with_label_wrap() {
463        let yaml = "info:\n  title: Test\nbibliography:\n  options:\n    label-mode: numeric\n    label-wrap: period\n";
464        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
465        let processor = Processor::new(style, Bibliography::new());
466
467        let warnings = bibliography_label_missing_separator_warnings(&processor);
468        assert!(
469            !warnings
470                .iter()
471                .any(|w| w.code == "bibliography_label_no_separator"),
472            "did not expect a warning once label-wrap is declared, got: {warnings:?}"
473        );
474    }
475
476    #[test]
477    fn bibliography_label_missing_separator_warnings_silent_with_label_separator() {
478        let yaml = "info:\n  title: Test\nbibliography:\n  options:\n    label-mode: numeric\n    label-separator: ' '\n";
479        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
480        let processor = Processor::new(style, Bibliography::new());
481
482        let warnings = bibliography_label_missing_separator_warnings(&processor);
483        assert!(
484            !warnings
485                .iter()
486                .any(|w| w.code == "bibliography_label_no_separator"),
487            "did not expect a warning once label-separator is declared, got: {warnings:?}"
488        );
489    }
490
491    #[test]
492    fn bibliography_label_missing_separator_warnings_silent_for_author_date_mode() {
493        let yaml = "info:\n  title: Test\nbibliography:\n  options:\n    label-mode: author-date\n";
494        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
495        let processor = Processor::new(style, Bibliography::new());
496
497        let warnings = bibliography_label_missing_separator_warnings(&processor);
498        assert!(
499            !warnings
500                .iter()
501                .any(|w| w.code == "bibliography_label_no_separator"),
502            "author-date mode never produces a bibliography marker, so wrap/separator are \
503             inert; did not expect a warning, got: {warnings:?}"
504        );
505    }
506
507    #[test]
508    fn bibliography_label_missing_separator_warnings_silent_with_no_label_mode() {
509        let yaml = "info:\n  title: Test\n";
510        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
511        let processor = Processor::new(style, Bibliography::new());
512
513        let warnings = bibliography_label_missing_separator_warnings(&processor);
514        assert!(
515            warnings.is_empty(),
516            "did not expect a warning with no bibliography options at all, got: {warnings:?}"
517        );
518    }
519
520    #[test]
521    fn unknown_enum_warnings_reports_unknown_term_in_integral_sub_spec() {
522        let yaml = "info:\n  title: Test\ncitation:\n  integral:\n    template:\n      - term: not-a-real-term\n";
523        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
524        let processor = Processor::new(style, Bibliography::new());
525
526        let warnings = unknown_enum_warnings(&processor);
527        assert!(
528            warnings
529                .iter()
530                .any(|w| w.message.contains("not-a-real-term") && w.message.contains("(integral)")),
531            "expected a warning for the unknown term in citation.integral.template, got: {warnings:?}"
532        );
533    }
534
535    #[test]
536    fn unknown_enum_warnings_reports_unknown_role_label_term() {
537        let yaml = "info:\n  title: Test\nbibliography:\n  template:\n    - contributor: editor\n      form: long\n      label: {term: not-a-real-role}\n";
538        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
539        let processor = Processor::new(style, Bibliography::new());
540
541        let warnings = unknown_enum_warnings(&processor);
542        assert!(
543            warnings
544                .iter()
545                .any(|w| w.code == "unknown_role_label_term"
546                    && w.message.contains("not-a-real-role")),
547            "expected a warning for the unrecognized role-label term, got: {warnings:?}"
548        );
549    }
550
551    #[test]
552    fn unknown_enum_warnings_does_not_flag_recognized_role_label_terms() {
553        let yaml = "info:\n  title: Test\nbibliography:\n  template:\n    - contributor: editor\n      form: long\n      label: {term: editor}\n";
554        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
555        let processor = Processor::new(style, Bibliography::new());
556
557        let warnings = unknown_enum_warnings(&processor);
558        assert!(
559            !warnings.iter().any(|w| w.code == "unknown_role_label_term"),
560            "did not expect a warning for a recognized role-label term, got: {warnings:?}"
561        );
562    }
563
564    #[test]
565    fn unknown_enum_warnings_does_not_flag_director_role_label_term() {
566        let yaml = "info:\n  title: Test\nbibliography:\n  template:\n    - contributor: director\n      form: long\n      label: {term: director, form: short}\n";
567        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
568        let processor = Processor::new(style, Bibliography::new());
569
570        let warnings = unknown_enum_warnings(&processor);
571        assert!(
572            !warnings.iter().any(|w| w.code == "unknown_role_label_term"),
573            "did not expect a warning for the director role-label term \
574             (chicago-author-date-18th's song type-variant relies on this), \
575             got: {warnings:?}"
576        );
577    }
578
579    #[test]
580    fn unknown_enum_warnings_reports_unknown_term_in_type_variants() {
581        let yaml = "info:\n  title: Test\ncitation:\n  type-variants:\n    book:\n      - term: not-a-real-term-2\n";
582        let style = citum_schema::Style::from_yaml_str(yaml).unwrap();
583        let processor = Processor::new(style, Bibliography::new());
584
585        let warnings = unknown_enum_warnings(&processor);
586        assert!(
587            warnings
588                .iter()
589                .any(|w| w.message.contains("not-a-real-term-2")),
590            "expected a warning for the unknown term in citation.type-variants, got: {warnings:?}"
591        );
592    }
593}