1use crate::api::AnnotationStyle;
9use crate::error::ProcessorError;
10use crate::processor::Processor;
11use crate::reference::Citation;
12use crate::render::djot::Djot;
13use crate::render::format::OutputFormat;
14use crate::render::html::Html;
15use crate::render::latex::Latex;
16use crate::render::markdown::Markdown;
17use crate::render::plain::PlainText;
18use crate::render::typst::Typst;
19use citum_schema::Style;
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24use super::warnings::{
25 unknown_enum_warnings, unknown_reference_class_warnings, unknown_reference_field_warnings,
26};
27use super::{
28 BibliographyEntry, CitationOccurrence, DocumentOptions, EntryMetadata, FormattedBibliography,
29 FormattedBibliographyBlock, FormattedCitation, OutputFormatKind, RefsInput, StyleInput,
30 Warning, WarningLevel,
31};
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FormatDocumentRequest {
36 pub style: StyleInput,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub style_overrides: Option<String>,
48 pub locale: Option<String>,
53 #[serde(default)]
56 pub output_format: OutputFormatKind,
57 pub refs: RefsInput,
59 pub citations: Vec<CitationOccurrence>,
61 #[serde(default)]
63 pub bibliography_blocks: Vec<super::BibliographyBlockRequest>,
64 pub document_options: Option<DocumentOptions>,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub nocite: Vec<String>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FormatDocumentResult {
80 pub formatted_citations: Vec<FormattedCitation>,
82 pub bibliography: FormattedBibliography,
84 pub bibliography_blocks: Vec<FormattedBibliographyBlock>,
86 pub warnings: Vec<Warning>,
88}
89
90#[derive(Debug)]
92pub enum FormatDocumentError {
93 UnresolvedInput(String),
95 StyleParse(String),
97 StylePath(String),
99 RefsInputPath(String),
101 RefsInputParse(String),
103 Processing(ProcessorError),
105 StyleResolution(String),
107}
108
109impl std::fmt::Display for FormatDocumentError {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::UnresolvedInput(msg) => write!(f, "Unresolved style input: {}", msg),
113 Self::StyleParse(msg) => write!(f, "Style parse error: {}", msg),
114 Self::StylePath(msg) => write!(f, "Style path error: {}", msg),
115 Self::RefsInputPath(msg) => write!(f, "Refs input path error: {}", msg),
116 Self::RefsInputParse(msg) => write!(f, "Refs input parse error: {}", msg),
117 Self::Processing(err) => write!(f, "Processing error: {}", err),
118 Self::StyleResolution(msg) => write!(f, "Style resolution error: {}", msg),
119 }
120 }
121}
122
123impl std::error::Error for FormatDocumentError {}
124
125impl From<ProcessorError> for FormatDocumentError {
126 fn from(err: ProcessorError) -> Self {
127 Self::Processing(err)
128 }
129}
130
131pub fn apply_style_overrides(
145 style: &mut Style,
146 overlay_src: &str,
147) -> Result<(), FormatDocumentError> {
148 let overlay = Style::from_yaml_bytes(overlay_src.as_bytes()).map_err(|e| {
149 FormatDocumentError::StyleParse(format!("Failed to parse style_overrides: {e}"))
150 })?;
151 style.apply_overlay(&overlay);
152 style.apply_scoped_options();
153 Ok(())
154}
155
156pub fn format_document(
166 request: FormatDocumentRequest,
167) -> Result<FormatDocumentResult, FormatDocumentError> {
168 let style = request.style.resolve_local()?;
169 format_document_with_style(style, request)
170}
171
172pub fn format_document_with_resolver(
183 request: FormatDocumentRequest,
184 resolver: &citum_schema::StyleResolver,
185) -> Result<FormatDocumentResult, FormatDocumentError> {
186 let style = match &request.style {
187 StyleInput::Yaml(_) => request.style.resolve_local()?,
188 StyleInput::Id(value) | StyleInput::Uri(value) | StyleInput::Path(value) => resolver
189 .resolve_style(value)
190 .map_err(|e| FormatDocumentError::UnresolvedInput(e.to_string()))?,
191 };
192 let mut resolved = style
196 .try_into_resolved_with(Some(resolver))
197 .map_err(|e| FormatDocumentError::StyleResolution(e.to_string()))?;
198 resolved.extends = None;
199 format_document_with_style(resolved, request)
200}
201
202#[allow(
211 clippy::too_many_lines,
212 reason = "match arms grow one-to-one with format variants"
213)]
214pub fn format_document_with_style(
215 style: Style,
216 request: FormatDocumentRequest,
217) -> Result<FormatDocumentResult, FormatDocumentError> {
218 let mut warnings = Vec::new();
219
220 let mut style = style;
222 if let Some(src) = &request.style_overrides {
223 apply_style_overrides(&mut style, src)?;
224 }
225
226 if let Some(tag) = &request.locale
231 && !tag.is_empty()
232 && !tag.eq_ignore_ascii_case("en-us")
233 {
234 warnings.push(Warning {
235 level: WarningLevel::Warning,
236 code: "locale_fallback".to_string(),
237 citation_id: None,
238 ref_id: None,
239 message: format!(
240 "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
241 ),
242 });
243 }
244
245 let bibliography = request.refs.resolve_local()?;
246 let mut processor = Processor::new(style, bibliography);
247 warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
248 warnings.extend(unknown_reference_field_warnings(&processor.bibliography));
249 warnings.extend(unknown_enum_warnings(&processor));
250
251 if let Some(opts) = &request.document_options {
252 if let Some(new_proc) = processor
256 .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
257 {
258 processor = new_proc;
259 }
260 if let Some(show_semantics) = opts.show_semantics {
261 processor.show_semantics = show_semantics;
262 }
263 if let Some(inject_ast) = opts.inject_ast_indices {
264 processor.set_inject_ast_indices(inject_ast);
265 }
266 if let Some(abbr_map) = opts.abbreviation_map.clone() {
267 processor.abbreviation_map = Some(abbr_map);
268 }
269 }
270
271 let mut citations: Vec<Citation> = Vec::new();
276 for occ in request.citations {
277 let mut citation: Citation = occ.into();
278 citation.items.retain(|item| {
279 if processor.bibliography.contains_key(&item.id) {
280 true
281 } else {
282 warnings.push(Warning {
283 level: WarningLevel::Warning,
284 code: "missing_ref".to_string(),
285 citation_id: citation.id.clone(),
286 ref_id: Some(item.id.clone()),
287 message: format!("Reference '{}' not found in bibliography", item.id),
288 });
289 false
290 }
291 });
292 citations.push(citation);
293 }
294
295 processor.annotate_flat_integral_name_states(&mut citations);
299
300 let formatted_citations = match request.output_format {
302 OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations)?,
303 OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations)?,
304 OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations)?,
305 OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations)?,
306 OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations)?,
307 OutputFormatKind::Markdown => format_by_kind::<Markdown>(&processor, &citations)?,
308 };
309
310 let nocite_ids: Vec<String> = request
313 .nocite
314 .iter()
315 .filter_map(|id| {
316 if processor.bibliography.contains_key(id) {
317 Some(id.clone())
318 } else {
319 warnings.push(Warning {
320 level: WarningLevel::Warning,
321 code: "nocite_missing_ref".to_string(),
322 citation_id: None,
323 ref_id: Some(id.clone()),
324 message: format!("Nocite reference '{id}' not found in bibliography"),
325 });
326 None
327 }
328 })
329 .collect();
330 processor.register_nocite_ids(nocite_ids);
331
332 let bibliography = match request.output_format {
334 OutputFormatKind::Plain => format_bibliography::<PlainText>(
335 &processor,
336 request.output_format,
337 request.document_options.as_ref(),
338 )?,
339 OutputFormatKind::Html => format_bibliography::<Html>(
340 &processor,
341 request.output_format,
342 request.document_options.as_ref(),
343 )?,
344 OutputFormatKind::Djot => format_bibliography::<Djot>(
345 &processor,
346 request.output_format,
347 request.document_options.as_ref(),
348 )?,
349 OutputFormatKind::Latex => format_bibliography::<Latex>(
350 &processor,
351 request.output_format,
352 request.document_options.as_ref(),
353 )?,
354 OutputFormatKind::Typst => format_bibliography::<Typst>(
355 &processor,
356 request.output_format,
357 request.document_options.as_ref(),
358 )?,
359 OutputFormatKind::Markdown => format_bibliography::<Markdown>(
360 &processor,
361 request.output_format,
362 request.document_options.as_ref(),
363 )?,
364 };
365
366 let bibliography_blocks = match request.output_format {
368 OutputFormatKind::Plain => format_bibliography_blocks::<PlainText>(
369 &processor,
370 &request.bibliography_blocks,
371 request.document_options.as_ref(),
372 )?,
373 OutputFormatKind::Html => format_bibliography_blocks::<Html>(
374 &processor,
375 &request.bibliography_blocks,
376 request.document_options.as_ref(),
377 )?,
378 OutputFormatKind::Djot => format_bibliography_blocks::<Djot>(
379 &processor,
380 &request.bibliography_blocks,
381 request.document_options.as_ref(),
382 )?,
383 OutputFormatKind::Latex => format_bibliography_blocks::<Latex>(
384 &processor,
385 &request.bibliography_blocks,
386 request.document_options.as_ref(),
387 )?,
388 OutputFormatKind::Typst => format_bibliography_blocks::<Typst>(
389 &processor,
390 &request.bibliography_blocks,
391 request.document_options.as_ref(),
392 )?,
393 OutputFormatKind::Markdown => format_bibliography_blocks::<Markdown>(
394 &processor,
395 &request.bibliography_blocks,
396 request.document_options.as_ref(),
397 )?,
398 };
399
400 Ok(FormatDocumentResult {
401 formatted_citations,
402 bibliography,
403 bibliography_blocks,
404 warnings,
405 })
406}
407
408pub(crate) fn format_by_kind<F>(
410 processor: &Processor,
411 citations: &[Citation],
412) -> Result<Vec<FormattedCitation>, FormatDocumentError>
413where
414 F: OutputFormat<Output = String>,
415{
416 let texts = processor.process_citations_with_format::<F>(citations)?;
417
418 let formatted = citations
419 .iter()
420 .zip(texts.iter())
421 .map(|(citation, text)| {
422 let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
423 FormattedCitation {
424 id: citation.id.clone().unwrap_or_default(),
425 text: text.clone(),
426 ref_ids,
427 }
428 })
429 .collect();
430
431 Ok(formatted)
432}
433
434pub(crate) fn format_bibliography<F>(
442 processor: &Processor,
443 format_kind: OutputFormatKind,
444 doc_opts: Option<&DocumentOptions>,
445) -> Result<FormattedBibliography, FormatDocumentError>
446where
447 F: OutputFormat<Output = String>,
448{
449 let (annotations, annotation_style) = annotation_options(doc_opts);
450 let doc_bib = processor.render_document_bibliography::<F>(
451 true,
452 if annotations.is_empty() {
453 None
454 } else {
455 Some(&annotations)
456 },
457 annotation_style.as_ref(),
458 );
459 let entries = doc_bib
460 .entries
461 .into_iter()
462 .map(|entry| {
463 proc_entry_to_bibliography_entry::<F>(
464 entry,
465 if annotations.is_empty() {
466 None
467 } else {
468 Some(&annotations)
469 },
470 annotation_style.as_ref(),
471 )
472 })
473 .collect();
474 Ok(FormattedBibliography {
475 format: format_kind,
476 content: doc_bib.content,
477 entries,
478 })
479}
480
481pub(crate) fn format_bibliography_blocks<F>(
486 processor: &Processor,
487 requests: &[super::BibliographyBlockRequest],
488 doc_opts: Option<&DocumentOptions>,
489) -> Result<Vec<super::FormattedBibliographyBlock>, FormatDocumentError>
490where
491 F: OutputFormat<Output = String>,
492{
493 if requests.is_empty() {
494 return Ok(Vec::new());
495 }
496
497 let (annotations, annotation_style) = annotation_options(doc_opts);
498 let groups: Vec<_> = requests.iter().map(|r| r.group.clone()).collect();
499 let rendered = processor.render_document_bibliography_blocks::<F>(
500 &groups,
501 if annotations.is_empty() {
502 None
503 } else {
504 Some(&annotations)
505 },
506 annotation_style.as_ref(),
507 );
508
509 Ok(requests
510 .iter()
511 .zip(rendered)
512 .map(|(req, rg)| super::FormattedBibliographyBlock {
513 id: req.id.clone(),
514 heading: rg.heading,
515 content: rg.body,
516 entries: rg
517 .entries
518 .into_iter()
519 .map(|entry| {
520 proc_entry_to_bibliography_entry::<F>(
521 entry,
522 if annotations.is_empty() {
523 None
524 } else {
525 Some(&annotations)
526 },
527 annotation_style.as_ref(),
528 )
529 })
530 .collect(),
531 })
532 .collect())
533}
534
535fn annotation_options(
537 doc_opts: Option<&DocumentOptions>,
538) -> (HashMap<String, String>, Option<AnnotationStyle>) {
539 if let Some(opts) = doc_opts
540 && let Some(anns) = &opts.annotations
541 {
542 let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
543 format: fmt.clone(),
544 });
545 return (anns.clone(), style);
546 }
547 (HashMap::new(), None)
548}
549
550fn proc_entry_to_bibliography_entry<F>(
552 entry: crate::render::ProcEntry,
553 annotations: Option<&HashMap<String, String>>,
554 annotation_style: Option<&AnnotationStyle>,
555) -> BibliographyEntry
556where
557 F: OutputFormat<Output = String>,
558{
559 let text = crate::render::bibliography::refs_to_string_slice_with_format::<F>(
560 std::slice::from_ref(&entry),
561 annotations,
562 annotation_style,
563 );
564 let metadata = EntryMetadata {
565 author: entry.metadata.author.unwrap_or_default(),
566 year: entry.metadata.year.unwrap_or_default(),
567 title: entry.metadata.title.unwrap_or_default(),
568 };
569 BibliographyEntry {
570 id: entry.id,
571 text,
572 metadata,
573 }
574}
575
576#[cfg(test)]
577#[allow(
578 clippy::unwrap_used,
579 clippy::expect_used,
580 clippy::panic,
581 clippy::indexing_slicing,
582 reason = "test code uses assertions and panic"
583)]
584mod tests {
585 use super::*;
586 use crate::api::CitationOccurrenceItem;
587 use crate::reference::Bibliography;
588 use crate::{
589 Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
590 TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
591 WrapPunctuation,
592 };
593 use citum_schema::options::{AndOptions, ContributorConfig};
594 use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
595 use citum_schema::template::{TemplateTitle, TitleType};
596 use citum_schema::{BibliographySpec, CitationSpec, StyleInfo};
597
598 fn make_test_style() -> Style {
599 Style {
600 info: StyleInfo {
601 title: Some("Test Style".to_string()),
602 id: Some("test".into()),
603 ..Default::default()
604 },
605 options: Some(Config {
606 processing: Some(Processing::AuthorDate),
607 ..Default::default()
608 }),
609 citation: Some(CitationSpec {
610 template: Some(vec![
611 TemplateComponent::Contributor(TemplateContributor {
612 contributor: ContributorRole::Author,
613 form: ContributorForm::Short,
614 rendering: Rendering::default(),
615 ..Default::default()
616 }),
617 TemplateComponent::Date(TemplateDate {
618 date: TemplateDateVariable::Issued,
619 form: DateForm::Year,
620 rendering: Rendering::default(),
621 ..Default::default()
622 }),
623 ]),
624 wrap: Some(WrapPunctuation::Parentheses.into()),
625 ..Default::default()
626 }),
627 ..Default::default()
628 }
629 }
630
631 fn make_test_bibliography() -> RefsInput {
632 let mut refs = Bibliography::new();
633 refs.insert(
634 "smith2020".to_string(),
635 InputReference::Monograph(Box::new(Monograph {
636 id: Some("smith2020".into()),
637 r#type: MonographType::Book,
638 title: Some(Title::Single("Sample Work".to_string())),
639 issued: EdtfString("2020".to_string()),
640 ..Default::default()
641 })),
642 );
643 RefsInput::Json(serde_json::to_value(refs).unwrap())
644 }
645
646 fn make_markup_bibliography() -> RefsInput {
647 let mut refs = Bibliography::new();
648 refs.insert(
649 "art1".to_string(),
650 InputReference::Monograph(Box::new(Monograph {
651 id: Some("art1".into()),
652 r#type: MonographType::Book,
653 title: Some(Title::Single(
654 "_Homo sapiens_ and *modern* world".to_string(),
655 )),
656 issued: EdtfString("2023".to_string()),
657 ..Default::default()
658 })),
659 );
660 RefsInput::Json(serde_json::to_value(refs).unwrap())
661 }
662
663 #[test]
664 fn format_document_with_style_empty_citations() {
665 let style = make_test_style();
666 let refs = make_test_bibliography();
667 let request = FormatDocumentRequest {
668 style: StyleInput::Yaml("dummy".to_string()),
669 style_overrides: None,
670 locale: None,
671 output_format: OutputFormatKind::Plain,
672 refs,
673 citations: vec![],
674 bibliography_blocks: Vec::new(),
675 document_options: None,
676 nocite: vec![],
677 };
678
679 let result = format_document_with_style(style, request);
680 assert!(result.is_ok());
681 let res = result.unwrap();
682 assert_eq!(res.formatted_citations.len(), 0);
683 }
684
685 #[test]
686 fn format_document_html_bibliography_entries_preserve_inline_markup() {
687 let mut style = make_test_style();
688 style.bibliography = Some(BibliographySpec {
689 template: Some(vec![TemplateComponent::Title(TemplateTitle {
690 title: TitleType::Primary,
691 ..Default::default()
692 })]),
693 ..Default::default()
694 });
695
696 let request = FormatDocumentRequest {
697 style: StyleInput::Yaml("dummy".to_string()),
698 style_overrides: None,
699 locale: None,
700 output_format: OutputFormatKind::Html,
701 refs: make_markup_bibliography(),
702 citations: vec![],
703 bibliography_blocks: Vec::new(),
704 document_options: None,
705 nocite: vec!["art1".to_string()],
708 };
709
710 let result = format_document_with_style(style, request).expect("should render");
711
712 assert_eq!(
713 result.bibliography.entries[0].text, result.bibliography.content,
714 "single-entry bibliography should mirror the full bibliography payload"
715 );
716 assert!(
717 result.bibliography.entries[0].text.contains(
718 "<span class=\"citum-title\"><em>Homo sapiens</em> and <b>modern</b> world</span>"
719 ),
720 "per-entry HTML should preserve inline markup for Djot-bearing titles"
721 );
722 }
723
724 #[test]
725 fn format_document_missing_ref_warning() {
726 let style = make_test_style();
727 let refs = make_test_bibliography();
728
729 let citation_occ = CitationOccurrence {
730 id: "cite1".to_string(),
731 items: vec![CitationOccurrenceItem {
732 id: "unknown_ref".to_string(),
733 locator: None,
734 prefix: None,
735 suffix: None,
736 integral_name_state: None,
737 org_abbreviation_state: None,
738 }],
739 mode: None,
740 note_number: None,
741 suppress_author: None,
742 grouped: None,
743 prefix: None,
744 suffix: None,
745 sentence_start: None,
746 };
747
748 let request = FormatDocumentRequest {
749 style: StyleInput::Yaml("dummy".to_string()),
750 style_overrides: None,
751 locale: None,
752 output_format: OutputFormatKind::Plain,
753 refs,
754 citations: vec![citation_occ],
755 bibliography_blocks: Vec::new(),
756 document_options: None,
757 nocite: vec![],
758 };
759
760 let result = format_document_with_style(style, request);
761 assert!(result.is_ok());
762 let res = result.unwrap();
763 assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
764 }
765
766 #[test]
767 fn format_document_unknown_reference_class_warning() {
768 let style = make_test_style();
769 let mut refs = Bibliography::new();
770 let unknown_ref: InputReference = serde_json::from_str(
771 r#"{
772 "class": "dance-performance",
773 "id": "pina2011",
774 "title": "Pina",
775 "issued": "2011",
776 "venue": "Berlin"
777 }"#,
778 )
779 .expect("unknown class should parse through the compatibility path");
780 refs.insert("pina2011".to_string(), unknown_ref);
781
782 let citation_occ = CitationOccurrence {
783 id: "cite1".to_string(),
784 items: vec![CitationOccurrenceItem {
785 id: "pina2011".to_string(),
786 locator: None,
787 prefix: None,
788 suffix: None,
789 integral_name_state: None,
790 org_abbreviation_state: None,
791 }],
792 mode: None,
793 note_number: None,
794 suppress_author: None,
795 grouped: None,
796 prefix: None,
797 suffix: None,
798 sentence_start: None,
799 };
800
801 let request = FormatDocumentRequest {
802 style: StyleInput::Yaml("dummy".to_string()),
803 style_overrides: None,
804 locale: None,
805 output_format: OutputFormatKind::Plain,
806 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
807 citations: vec![citation_occ],
808 bibliography_blocks: Vec::new(),
809 document_options: None,
810 nocite: vec![],
811 };
812
813 let result = format_document_with_style(style, request).unwrap();
814 let warning = result
815 .warnings
816 .iter()
817 .find(|w| w.code == "unknown_reference_class")
818 .expect("unknown class warning should be emitted");
819 assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
820 assert!(warning.message.contains("dance-performance"));
821 }
822
823 #[test]
824 fn format_document_yaml_style_input() {
825 let style = make_test_style();
826 let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
827
828 let mut refs = Bibliography::new();
829 refs.insert(
830 "test2024".to_string(),
831 InputReference::Monograph(Box::new(Monograph {
832 id: Some("test2024".into()),
833 r#type: MonographType::Book,
834 title: Some(Title::Single("Test Work".to_string())),
835 issued: EdtfString("2024".to_string()),
836 ..Default::default()
837 })),
838 );
839
840 let citation_occ = CitationOccurrence {
841 id: "c1".to_string(),
842 items: vec![CitationOccurrenceItem {
843 id: "test2024".to_string(),
844 locator: None,
845 prefix: None,
846 suffix: None,
847 integral_name_state: None,
848 org_abbreviation_state: None,
849 }],
850 mode: None,
851 note_number: None,
852 suppress_author: None,
853 grouped: None,
854 prefix: None,
855 suffix: None,
856 sentence_start: None,
857 };
858
859 let request = FormatDocumentRequest {
860 style: StyleInput::Yaml(yaml_style),
861 style_overrides: None,
862 locale: None,
863 output_format: OutputFormatKind::Plain,
864 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
865 citations: vec![citation_occ],
866 bibliography_blocks: Vec::new(),
867 document_options: None,
868 nocite: vec![],
869 };
870
871 let result = format_document(request);
872 assert!(result.is_ok());
873 let res = result.unwrap();
874 assert_eq!(res.formatted_citations.len(), 1);
875 assert!(!res.formatted_citations[0].text.is_empty());
876 }
877
878 #[test]
879 fn format_document_uri_input_unresolved() {
880 let request = FormatDocumentRequest {
881 style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
882 style_overrides: None,
883 locale: None,
884 output_format: OutputFormatKind::Plain,
885 refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
886 citations: vec![],
887 bibliography_blocks: Vec::new(),
888 document_options: None,
889 nocite: vec![],
890 };
891
892 let result = format_document(request);
893 match result {
894 Err(FormatDocumentError::UnresolvedInput(_)) => {
895 }
897 _ => panic!("Expected UnresolvedInput error"),
898 }
899 }
900
901 struct MockResolver(Style);
903
904 impl citum_resolver_api::StyleResolver for MockResolver {
905 type Style = Style;
906 type Locale = citum_schema::locale::Locale;
907
908 fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
909 Ok(self.0.clone())
910 }
911
912 fn resolve_locale(
913 &self,
914 id: &str,
915 ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
916 Err(citum_schema::ResolverError::LocaleNotFound(
917 std::borrow::Cow::Owned(id.to_string()),
918 ))
919 }
920 }
921
922 #[test]
923 fn format_document_with_resolver_injects_style_for_id_input() {
924 let style = make_test_style();
925 let resolver = MockResolver(style);
926 let refs = make_test_bibliography();
927
928 let citation_occ = CitationOccurrence {
929 id: "c1".to_string(),
930 items: vec![CitationOccurrenceItem {
931 id: "smith2020".to_string(),
932 locator: None,
933 prefix: None,
934 suffix: None,
935 integral_name_state: None,
936 org_abbreviation_state: None,
937 }],
938 mode: None,
939 note_number: None,
940 suppress_author: None,
941 grouped: None,
942 prefix: None,
943 suffix: None,
944 sentence_start: None,
945 };
946
947 let request = FormatDocumentRequest {
948 style: StyleInput::Id("any-id".to_string()),
949 style_overrides: None,
950 locale: None,
951 output_format: OutputFormatKind::Plain,
952 refs,
953 citations: vec![citation_occ],
954 bibliography_blocks: Vec::new(),
955 document_options: None,
956 nocite: vec![],
957 };
958
959 match format_document(request.clone()) {
961 Err(FormatDocumentError::UnresolvedInput(_)) => {}
962 other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
963 }
964
965 let result = format_document_with_resolver(request, &resolver);
967 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
968 let res = result.unwrap();
969 assert_eq!(res.formatted_citations.len(), 1);
970 assert!(
971 !res.formatted_citations[0].text.is_empty(),
972 "formatted citation text should not be empty"
973 );
974 }
975
976 fn make_two_author_style() -> Style {
978 Style {
979 info: StyleInfo {
980 title: Some("Override Test Style".to_string()),
981 id: Some("override-test".into()),
982 ..Default::default()
983 },
984 options: Some(Config {
985 processing: Some(Processing::AuthorDate),
986 contributors: Some(ContributorConfig {
989 and: Some(AndOptions::Text),
990 ..Default::default()
991 }),
992 ..Default::default()
993 }),
994 citation: Some(CitationSpec {
995 template: Some(vec![
996 TemplateComponent::Contributor(TemplateContributor {
997 contributor: ContributorRole::Author,
998 form: ContributorForm::Short,
999 rendering: Rendering::default(),
1000 ..Default::default()
1001 }),
1002 TemplateComponent::Date(TemplateDate {
1003 date: TemplateDateVariable::Issued,
1004 form: DateForm::Year,
1005 rendering: Rendering {
1006 prefix: Some(", ".to_string()),
1007 ..Default::default()
1008 },
1009 ..Default::default()
1010 }),
1011 ]),
1012 wrap: Some(WrapPunctuation::Parentheses.into()),
1013 ..Default::default()
1014 }),
1015 ..Default::default()
1016 }
1017 }
1018
1019 fn make_two_author_refs() -> RefsInput {
1025 RefsInput::Yaml(
1026 r#"duo2024:
1027 class: monograph
1028 id: duo2024
1029 type: book
1030 title: Duo Work
1031 issued: "2024"
1032 author:
1033 - family: Smith
1034 given: Alice
1035 - family: Jones
1036 given: Bob
1037"#
1038 .to_string(),
1039 )
1040 }
1041
1042 fn cite(ref_id: &str) -> CitationOccurrence {
1044 CitationOccurrence {
1045 id: "c1".to_string(),
1046 items: vec![CitationOccurrenceItem {
1047 id: ref_id.to_string(),
1048 locator: None,
1049 prefix: None,
1050 suffix: None,
1051 integral_name_state: None,
1052 org_abbreviation_state: None,
1053 }],
1054 mode: None,
1055 note_number: None,
1056 suppress_author: None,
1057 grouped: None,
1058 prefix: None,
1059 suffix: None,
1060 sentence_start: None,
1061 }
1062 }
1063
1064 #[test]
1065 fn style_overrides_and_symbol_changes_rendered_output() {
1066 let base_style = make_two_author_style();
1067 let refs = make_two_author_refs();
1068
1069 let request_base = FormatDocumentRequest {
1071 style: StyleInput::Yaml("dummy".to_string()),
1072 style_overrides: None,
1073 locale: None,
1074 output_format: OutputFormatKind::Plain,
1075 refs: refs.clone(),
1076 citations: vec![cite("duo2024")],
1077 bibliography_blocks: Vec::new(),
1078 document_options: None,
1079 nocite: vec![],
1080 };
1081 let result_base = format_document_with_style(base_style.clone(), request_base).unwrap();
1082 let text_base = &result_base.formatted_citations[0].text;
1083 assert!(
1084 text_base.contains("and"),
1085 "base style should use text 'and' connector, got: {text_base:?}"
1086 );
1087
1088 let request_override = FormatDocumentRequest {
1090 style: StyleInput::Yaml("dummy".to_string()),
1091 style_overrides: Some("options:\n contributors:\n and: symbol\n".to_string()),
1092 locale: None,
1093 output_format: OutputFormatKind::Plain,
1094 refs,
1095 citations: vec![cite("duo2024")],
1096 bibliography_blocks: Vec::new(),
1097 document_options: None,
1098 nocite: vec![],
1099 };
1100 let result_override =
1101 format_document_with_style(base_style.clone(), request_override).unwrap();
1102 let text_override = &result_override.formatted_citations[0].text;
1103 assert!(
1104 text_override.contains('&'),
1105 "overridden style should use '&' connector, got: {text_override:?}"
1106 );
1107
1108 let base_and = base_style
1110 .options
1111 .as_ref()
1112 .and_then(|o| o.contributors.as_ref())
1113 .and_then(|c| c.and.as_ref());
1114 assert!(
1115 matches!(base_and, Some(&AndOptions::Text)),
1116 "base style must not be mutated; expected And::Text, got: {base_and:?}"
1117 );
1118 }
1119
1120 #[test]
1121 fn style_overrides_invalid_yaml_returns_parse_error() {
1122 let style = make_test_style();
1123 let refs = make_test_bibliography();
1124
1125 let request = FormatDocumentRequest {
1126 style: StyleInput::Yaml("dummy".to_string()),
1127 style_overrides: Some("{ unclosed yaml: [".to_string()),
1128 locale: None,
1129 output_format: OutputFormatKind::Plain,
1130 refs,
1131 citations: vec![],
1132 bibliography_blocks: Vec::new(),
1133 document_options: None,
1134 nocite: vec![],
1135 };
1136
1137 match format_document_with_style(style, request) {
1138 Err(FormatDocumentError::StyleParse(msg)) => {
1139 assert!(
1140 msg.contains("style_overrides"),
1141 "error message should mention style_overrides, got: {msg}"
1142 );
1143 }
1144 other => panic!("expected StyleParse error, got: {other:?}"),
1145 }
1146 }
1147
1148 #[test]
1149 fn apply_style_overrides_merges_option_field() {
1150 let mut style = make_test_style();
1151 apply_style_overrides(&mut style, "options:\n contributors:\n and: symbol\n")
1152 .expect("apply_style_overrides should succeed");
1153
1154 let and_option = style
1155 .options
1156 .as_ref()
1157 .and_then(|o| o.contributors.as_ref())
1158 .and_then(|c| c.and.as_ref());
1159 assert!(
1160 matches!(and_option, Some(&AndOptions::Symbol)),
1161 "expected And::Symbol after override, got: {and_option:?}"
1162 );
1163 }
1164
1165 fn make_integral_name_style() -> Style {
1171 use citum_schema::options::{
1172 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1173 };
1174 Style {
1175 info: StyleInfo {
1176 title: Some("Integral Name Memory Test".to_string()),
1177 id: Some("integral-name-memory-test".into()),
1178 ..Default::default()
1179 },
1180 options: Some(Config {
1181 processing: Some(Processing::AuthorDate),
1182 integral_name_memory: Some(IntegralNameMemoryConfig {
1183 scope: Some(IntegralNameScope::Document),
1184 contexts: Some(IntegralNameContexts::BodyAndNotes),
1185 subsequent_form: Some(SubsequentNameForm::Short),
1186 ..Default::default()
1187 }),
1188 ..Default::default()
1189 }),
1190 citation: Some(CitationSpec {
1191 integral: Some(Box::new(CitationSpec {
1192 template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1193 contributor: ContributorRole::Author,
1194 form: ContributorForm::Long,
1195 rendering: Rendering::default(),
1196 ..Default::default()
1197 })]),
1198 ..Default::default()
1199 })),
1200 template: Some(vec![
1201 TemplateComponent::Contributor(TemplateContributor {
1202 contributor: ContributorRole::Author,
1203 form: ContributorForm::Short,
1204 rendering: Rendering::default(),
1205 ..Default::default()
1206 }),
1207 TemplateComponent::Date(TemplateDate {
1208 date: TemplateDateVariable::Issued,
1209 form: DateForm::Year,
1210 rendering: Rendering::default(),
1211 ..Default::default()
1212 }),
1213 ]),
1214 wrap: Some(WrapPunctuation::Parentheses.into()),
1215 ..Default::default()
1216 }),
1217 ..Default::default()
1218 }
1219 }
1220
1221 fn make_smith_refs() -> RefsInput {
1222 RefsInput::Yaml(
1223 r#"smith2020:
1224 class: monograph
1225 id: smith2020
1226 type: book
1227 title: Smith Book
1228 issued: "2020"
1229 author:
1230 - family: Smith
1231 given: John
1232"#
1233 .to_string(),
1234 )
1235 }
1236
1237 fn make_integral_occ(id: &str, ref_id: &str) -> CitationOccurrence {
1238 CitationOccurrence {
1239 id: id.to_string(),
1240 items: vec![CitationOccurrenceItem {
1241 id: ref_id.to_string(),
1242 locator: None,
1243 prefix: None,
1244 suffix: None,
1245 integral_name_state: None,
1246 org_abbreviation_state: None,
1247 }],
1248 mode: Some(citum_schema::data::citation::CitationMode::Integral),
1249 note_number: None,
1250 suppress_author: None,
1251 grouped: None,
1252 prefix: None,
1253 suffix: None,
1254 sentence_start: None,
1255 }
1256 }
1257
1258 #[test]
1259 fn document_options_integral_name_memory_first_full_then_short() {
1260 use crate::processor::document::DocumentIntegralNameOverride;
1261
1262 let style = make_integral_name_style();
1263 let refs = make_smith_refs();
1264
1265 let request = FormatDocumentRequest {
1266 style: StyleInput::Yaml("dummy".to_string()),
1267 style_overrides: None,
1268 locale: None,
1269 output_format: OutputFormatKind::Plain,
1270 refs,
1271 citations: vec![
1272 make_integral_occ("c1", "smith2020"),
1273 make_integral_occ("c2", "smith2020"),
1274 ],
1275 bibliography_blocks: Vec::new(),
1276 document_options: Some(DocumentOptions {
1277 integral_name_memory: Some(DocumentIntegralNameOverride {
1278 enabled: Some(true),
1279 ..Default::default()
1280 }),
1281 ..Default::default()
1282 }),
1283 nocite: vec![],
1284 };
1285
1286 let result = format_document_with_style(style, request).expect("should render");
1287
1288 assert!(
1289 !result
1290 .warnings
1291 .iter()
1292 .any(|w| w.code == "integral_name_memory_not_applied"),
1293 "stale warning must not appear: {:?}",
1294 result.warnings
1295 );
1296 assert_eq!(
1297 result.formatted_citations[0].text, "John Smith",
1298 "first integral cite should render full name form"
1299 );
1300 assert_eq!(
1301 result.formatted_citations[1].text, "Smith",
1302 "second integral cite of same author should render short form"
1303 );
1304 }
1305
1306 #[test]
1307 fn document_options_integral_name_memory_disabled_keeps_full_form() {
1308 use crate::processor::document::DocumentIntegralNameOverride;
1309
1310 let style = make_integral_name_style();
1311 let refs = make_smith_refs();
1312
1313 let request = FormatDocumentRequest {
1314 style: StyleInput::Yaml("dummy".to_string()),
1315 style_overrides: None,
1316 locale: None,
1317 output_format: OutputFormatKind::Plain,
1318 refs,
1319 citations: vec![
1320 make_integral_occ("c1", "smith2020"),
1321 make_integral_occ("c2", "smith2020"),
1322 ],
1323 bibliography_blocks: Vec::new(),
1324 document_options: Some(DocumentOptions {
1325 integral_name_memory: Some(DocumentIntegralNameOverride {
1326 enabled: Some(false),
1327 ..Default::default()
1328 }),
1329 ..Default::default()
1330 }),
1331 nocite: vec![],
1332 };
1333
1334 let result = format_document_with_style(style, request).expect("should render");
1335
1336 assert_eq!(
1339 result.formatted_citations[0].text, "John Smith",
1340 "first integral cite: {}",
1341 result.formatted_citations[0].text
1342 );
1343 assert_eq!(
1344 result.formatted_citations[1].text, "John Smith",
1345 "second integral cite should also be full when memory is disabled"
1346 );
1347 }
1348
1349 #[test]
1350 fn style_native_integral_name_memory_applied_without_document_override() {
1351 let style = make_integral_name_style();
1354 let refs = make_smith_refs();
1355
1356 let request = FormatDocumentRequest {
1357 style: StyleInput::Yaml("dummy".to_string()),
1358 style_overrides: None,
1359 locale: None,
1360 output_format: OutputFormatKind::Plain,
1361 refs,
1362 citations: vec![
1363 make_integral_occ("c1", "smith2020"),
1364 make_integral_occ("c2", "smith2020"),
1365 ],
1366 bibliography_blocks: Vec::new(),
1367 document_options: None,
1368 nocite: vec![],
1369 };
1370
1371 let result = format_document_with_style(style, request).expect("should render");
1372
1373 assert_eq!(
1374 result.formatted_citations[0].text, "John Smith",
1375 "first integral cite should render full name form"
1376 );
1377 assert_eq!(
1378 result.formatted_citations[1].text, "Smith",
1379 "second integral cite should render short form from style-native config"
1380 );
1381 }
1382
1383 #[test]
1384 fn format_document_bibliography_blocks_ordered_with_dedup() {
1385 use citum_schema::grouping::CitedStatus;
1386 use citum_schema::grouping::{BibliographyGroup, GroupSelector};
1387
1388 let mut style = make_test_style();
1389 style.bibliography = Some(BibliographySpec {
1390 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1391 title: TitleType::Primary,
1392 ..Default::default()
1393 })]),
1394 ..Default::default()
1395 });
1396 let mut refs = Bibliography::new();
1397 refs.insert(
1398 "smith2020".to_string(),
1399 InputReference::Monograph(Box::new(Monograph {
1400 id: Some("smith2020".into()),
1401 r#type: MonographType::Book,
1402 title: Some(Title::Single("Sample Work".to_string())),
1403 issued: EdtfString("2020".to_string()),
1404 ..Default::default()
1405 })),
1406 );
1407 refs.insert(
1408 "jones2019".to_string(),
1409 InputReference::Monograph(Box::new(Monograph {
1410 id: Some("jones2019".into()),
1411 r#type: MonographType::Book,
1412 title: Some(Title::Single("Another Work".to_string())),
1413 issued: EdtfString("2019".to_string()),
1414 ..Default::default()
1415 })),
1416 );
1417
1418 let make_block = |id: &str| crate::BibliographyBlockRequest {
1419 id: id.to_string(),
1420 group: BibliographyGroup {
1421 id: id.to_string(),
1422 selector: GroupSelector {
1423 cited: Some(CitedStatus::Any),
1424 ..Default::default()
1425 },
1426 ..Default::default()
1427 },
1428 };
1429
1430 let request = FormatDocumentRequest {
1431 style: StyleInput::Yaml("dummy".to_string()),
1432 style_overrides: None,
1433 locale: None,
1434 output_format: OutputFormatKind::Plain,
1435 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1436 citations: vec![],
1437 bibliography_blocks: vec![make_block("block-a"), make_block("block-b")],
1438 document_options: None,
1439 nocite: vec![],
1440 };
1441
1442 let result = format_document_with_style(style, request).expect("should render");
1443
1444 assert_eq!(result.bibliography_blocks.len(), 2, "both blocks returned");
1445 assert_eq!(result.bibliography_blocks[0].id, "block-a");
1446 assert_eq!(result.bibliography_blocks[1].id, "block-b");
1447
1448 let block_a_count = result.bibliography_blocks[0].entries.len();
1449 let block_b_count = result.bibliography_blocks[1].entries.len();
1450
1451 assert_eq!(block_a_count, 2, "block-a captures both refs");
1452 assert_eq!(
1453 block_b_count, 0,
1454 "block-b is empty: dedup set prevents re-assignment from block-a"
1455 );
1456 }
1457
1458 #[test]
1463 fn nocite_ref_in_bibliography_not_in_formatted_citations() {
1464 let mut style = make_test_style();
1465 style.bibliography = Some(BibliographySpec {
1467 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1468 title: TitleType::Primary,
1469 ..Default::default()
1470 })]),
1471 ..Default::default()
1472 });
1473 let refs = make_test_bibliography(); let request = FormatDocumentRequest {
1476 style: StyleInput::Yaml("dummy".to_string()),
1477 style_overrides: None,
1478 locale: None,
1479 output_format: OutputFormatKind::Plain,
1480 refs,
1481 citations: vec![],
1482 bibliography_blocks: Vec::new(),
1483 document_options: None,
1484 nocite: vec!["smith2020".to_string()],
1485 };
1486
1487 let result = format_document_with_style(style, request).expect("should render");
1488
1489 assert_eq!(
1490 result.formatted_citations.len(),
1491 0,
1492 "nocite refs must not produce a formatted citation"
1493 );
1494 assert_eq!(
1495 result.bibliography.entries.len(),
1496 1,
1497 "nocite ref must appear in bibliography entries"
1498 );
1499 assert_eq!(
1500 result.bibliography.entries[0].id, "smith2020",
1501 "bibliography entry id should match nocite ref"
1502 );
1503 assert!(
1504 !result.bibliography.content.is_empty(),
1505 "bibliography content must be non-empty for nocite ref"
1506 );
1507 assert!(
1508 result.warnings.is_empty(),
1509 "no warnings expected: {:?}",
1510 result.warnings
1511 );
1512 }
1513
1514 #[test]
1517 fn nocite_missing_ref_emits_warning() {
1518 let style = make_test_style();
1519 let refs = make_test_bibliography();
1520
1521 let request = FormatDocumentRequest {
1522 style: StyleInput::Yaml("dummy".to_string()),
1523 style_overrides: None,
1524 locale: None,
1525 output_format: OutputFormatKind::Plain,
1526 refs,
1527 citations: vec![],
1528 bibliography_blocks: Vec::new(),
1529 document_options: None,
1530 nocite: vec!["does_not_exist".to_string()],
1531 };
1532
1533 let result = format_document_with_style(style, request).expect("should render");
1534
1535 assert_eq!(
1536 result.bibliography.entries.len(),
1537 0,
1538 "absent nocite ref must not produce a bibliography entry"
1539 );
1540 let warning = result
1541 .warnings
1542 .iter()
1543 .find(|w| w.code == "nocite_missing_ref")
1544 .expect("nocite_missing_ref warning should be emitted");
1545 assert_eq!(
1546 warning.ref_id.as_deref(),
1547 Some("does_not_exist"),
1548 "warning ref_id should name the absent nocite key"
1549 );
1550 }
1551
1552 #[test]
1555 fn nocite_ref_sorts_alongside_cited_ref() {
1556 let mut style = make_test_style();
1557 style.bibliography = Some(BibliographySpec {
1558 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1559 title: TitleType::Primary,
1560 ..Default::default()
1561 })]),
1562 ..Default::default()
1563 });
1564
1565 let citation_occ = CitationOccurrence {
1566 id: "c1".to_string(),
1567 items: vec![CitationOccurrenceItem {
1568 id: "duo2024".to_string(),
1569 locator: None,
1570 prefix: None,
1571 suffix: None,
1572 integral_name_state: None,
1573 org_abbreviation_state: None,
1574 }],
1575 mode: None,
1576 note_number: None,
1577 suppress_author: None,
1578 grouped: None,
1579 prefix: None,
1580 suffix: None,
1581 sentence_start: None,
1582 };
1583
1584 let combined_refs = RefsInput::Yaml(
1586 r#"duo2024:
1587 class: monograph
1588 id: duo2024
1589 type: book
1590 title: Duo Work
1591 issued: "2024"
1592 author:
1593 - family: Smith
1594 given: Alice
1595 - family: Jones
1596 given: Bob
1597smith2020:
1598 class: monograph
1599 id: smith2020
1600 type: book
1601 title: Smith Work
1602 issued: "2020"
1603 author:
1604 - family: Smith
1605 given: Alex
1606"#
1607 .to_string(),
1608 );
1609
1610 let request = FormatDocumentRequest {
1611 style: StyleInput::Yaml("dummy".to_string()),
1612 style_overrides: None,
1613 locale: None,
1614 output_format: OutputFormatKind::Plain,
1615 refs: combined_refs,
1616 citations: vec![citation_occ],
1617 bibliography_blocks: Vec::new(),
1618 document_options: None,
1619 nocite: vec!["smith2020".to_string()],
1620 };
1621
1622 let result = format_document_with_style(style, request).expect("should render");
1623
1624 assert_eq!(result.formatted_citations.len(), 1, "one in-text citation");
1625 assert_eq!(
1626 result.bibliography.entries.len(),
1627 2,
1628 "both cited and nocite refs must appear in the bibliography"
1629 );
1630 let ids: Vec<&str> = result
1631 .bibliography
1632 .entries
1633 .iter()
1634 .map(|e| e.id.as_str())
1635 .collect();
1636 assert!(
1637 ids.contains(&"duo2024"),
1638 "cited ref must be in bibliography: {ids:?}"
1639 );
1640 assert!(
1641 ids.contains(&"smith2020"),
1642 "nocite ref must be in bibliography: {ids:?}"
1643 );
1644 }
1645}