1use 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
27pub 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
73pub 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
112pub 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
133pub 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
161pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
166 let mut warnings = Vec::new();
167
168 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 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
268fn 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
306fn 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}