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::data::citation::CitationMode;
594 use citum_schema::options::{AndOptions, ContributorConfig};
595 use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
596 use citum_schema::template::{TemplateTitle, TitleType};
597 use citum_schema::{BibliographySpec, CitationSpec, StyleInfo};
598 use std::collections::HashMap;
599
600 fn make_test_style() -> Style {
601 Style {
602 info: StyleInfo {
603 title: Some("Test Style".to_string()),
604 id: Some("test".into()),
605 ..Default::default()
606 },
607 options: Some(Config {
608 processing: Some(Processing::AuthorDate),
609 ..Default::default()
610 }),
611 citation: Some(CitationSpec {
612 template: Some(vec![
613 TemplateComponent::Contributor(TemplateContributor {
614 contributor: ContributorRole::Author,
615 form: ContributorForm::Short,
616 rendering: Rendering::default(),
617 ..Default::default()
618 }),
619 TemplateComponent::Date(TemplateDate {
620 date: TemplateDateVariable::Issued,
621 form: DateForm::Year,
622 rendering: Rendering::default(),
623 ..Default::default()
624 }),
625 ]),
626 wrap: Some(WrapPunctuation::Parentheses.into()),
627 ..Default::default()
628 }),
629 ..Default::default()
630 }
631 }
632
633 fn make_test_bibliography() -> RefsInput {
634 let mut refs = Bibliography::new();
635 refs.insert(
636 "smith2020".to_string(),
637 InputReference::Monograph(Box::new(Monograph {
638 id: Some("smith2020".into()),
639 r#type: MonographType::Book,
640 title: Some(Title::Single("Sample Work".to_string())),
641 issued: EdtfString("2020".to_string()),
642 ..Default::default()
643 })),
644 );
645 RefsInput::Json(serde_json::to_value(refs).unwrap())
646 }
647
648 fn make_markup_bibliography() -> RefsInput {
649 let mut refs = Bibliography::new();
650 refs.insert(
651 "art1".to_string(),
652 InputReference::Monograph(Box::new(Monograph {
653 id: Some("art1".into()),
654 r#type: MonographType::Book,
655 title: Some(Title::Single(
656 "_Homo sapiens_ and *modern* world".to_string(),
657 )),
658 issued: EdtfString("2023".to_string()),
659 ..Default::default()
660 })),
661 );
662 RefsInput::Json(serde_json::to_value(refs).unwrap())
663 }
664
665 #[test]
666 fn format_document_with_style_empty_citations() {
667 let style = make_test_style();
668 let refs = make_test_bibliography();
669 let request = FormatDocumentRequest {
670 style: StyleInput::Yaml("dummy".to_string()),
671 style_overrides: None,
672 locale: None,
673 output_format: OutputFormatKind::Plain,
674 refs,
675 citations: vec![],
676 bibliography_blocks: Vec::new(),
677 document_options: None,
678 nocite: vec![],
679 };
680
681 let result = format_document_with_style(style, request);
682 assert!(result.is_ok());
683 let res = result.unwrap();
684 assert_eq!(res.formatted_citations.len(), 0);
685 }
686
687 #[test]
688 fn format_document_html_bibliography_entries_preserve_inline_markup() {
689 let mut style = make_test_style();
690 style.bibliography = Some(BibliographySpec {
691 template: Some(vec![TemplateComponent::Title(TemplateTitle {
692 title: TitleType::Primary,
693 ..Default::default()
694 })]),
695 ..Default::default()
696 });
697
698 let request = FormatDocumentRequest {
699 style: StyleInput::Yaml("dummy".to_string()),
700 style_overrides: None,
701 locale: None,
702 output_format: OutputFormatKind::Html,
703 refs: make_markup_bibliography(),
704 citations: vec![],
705 bibliography_blocks: Vec::new(),
706 document_options: None,
707 nocite: vec!["art1".to_string()],
710 };
711
712 let result = format_document_with_style(style, request).expect("should render");
713
714 assert_eq!(
715 result.bibliography.entries[0].text, result.bibliography.content,
716 "single-entry bibliography should mirror the full bibliography payload"
717 );
718 assert!(
719 result.bibliography.entries[0].text.contains(
720 "<span class=\"citum-title\"><em>Homo sapiens</em> and <b>modern</b> world</span>"
721 ),
722 "per-entry HTML should preserve inline markup for Djot-bearing titles"
723 );
724 }
725
726 #[test]
727 fn format_document_missing_ref_warning() {
728 let style = make_test_style();
729 let refs = make_test_bibliography();
730
731 let citation_occ = CitationOccurrence {
732 id: "cite1".to_string(),
733 items: vec![CitationOccurrenceItem {
734 id: "unknown_ref".to_string(),
735 locator: None,
736 prefix: None,
737 suffix: None,
738 integral_name_state: None,
739 org_abbreviation_state: None,
740 }],
741 mode: None,
742 note_number: None,
743 suppress_author: None,
744 grouped: None,
745 prefix: None,
746 suffix: None,
747 sentence_start: None,
748 };
749
750 let request = FormatDocumentRequest {
751 style: StyleInput::Yaml("dummy".to_string()),
752 style_overrides: None,
753 locale: None,
754 output_format: OutputFormatKind::Plain,
755 refs,
756 citations: vec![citation_occ],
757 bibliography_blocks: Vec::new(),
758 document_options: None,
759 nocite: vec![],
760 };
761
762 let result = format_document_with_style(style, request);
763 assert!(result.is_ok());
764 let res = result.unwrap();
765 assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
766 }
767
768 #[test]
769 fn format_document_unknown_reference_class_warning() {
770 let style = make_test_style();
771 let mut refs = Bibliography::new();
772 let unknown_ref: InputReference = serde_json::from_str(
773 r#"{
774 "class": "dance-performance",
775 "id": "pina2011",
776 "title": "Pina",
777 "issued": "2011",
778 "venue": "Berlin"
779 }"#,
780 )
781 .expect("unknown class should parse through the compatibility path");
782 refs.insert("pina2011".to_string(), unknown_ref);
783
784 let citation_occ = CitationOccurrence {
785 id: "cite1".to_string(),
786 items: vec![CitationOccurrenceItem {
787 id: "pina2011".to_string(),
788 locator: None,
789 prefix: None,
790 suffix: None,
791 integral_name_state: None,
792 org_abbreviation_state: None,
793 }],
794 mode: None,
795 note_number: None,
796 suppress_author: None,
797 grouped: None,
798 prefix: None,
799 suffix: None,
800 sentence_start: None,
801 };
802
803 let request = FormatDocumentRequest {
804 style: StyleInput::Yaml("dummy".to_string()),
805 style_overrides: None,
806 locale: None,
807 output_format: OutputFormatKind::Plain,
808 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
809 citations: vec![citation_occ],
810 bibliography_blocks: Vec::new(),
811 document_options: None,
812 nocite: vec![],
813 };
814
815 let result = format_document_with_style(style, request).unwrap();
816 let warning = result
817 .warnings
818 .iter()
819 .find(|w| w.code == "unknown_reference_class")
820 .expect("unknown class warning should be emitted");
821 assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
822 assert!(warning.message.contains("dance-performance"));
823 }
824
825 #[test]
826 fn format_document_yaml_style_input() {
827 let style = make_test_style();
828 let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
829
830 let mut refs = Bibliography::new();
831 refs.insert(
832 "test2024".to_string(),
833 InputReference::Monograph(Box::new(Monograph {
834 id: Some("test2024".into()),
835 r#type: MonographType::Book,
836 title: Some(Title::Single("Test Work".to_string())),
837 issued: EdtfString("2024".to_string()),
838 ..Default::default()
839 })),
840 );
841
842 let citation_occ = CitationOccurrence {
843 id: "c1".to_string(),
844 items: vec![CitationOccurrenceItem {
845 id: "test2024".to_string(),
846 locator: None,
847 prefix: None,
848 suffix: None,
849 integral_name_state: None,
850 org_abbreviation_state: None,
851 }],
852 mode: None,
853 note_number: None,
854 suppress_author: None,
855 grouped: None,
856 prefix: None,
857 suffix: None,
858 sentence_start: None,
859 };
860
861 let request = FormatDocumentRequest {
862 style: StyleInput::Yaml(yaml_style),
863 style_overrides: None,
864 locale: None,
865 output_format: OutputFormatKind::Plain,
866 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
867 citations: vec![citation_occ],
868 bibliography_blocks: Vec::new(),
869 document_options: None,
870 nocite: vec![],
871 };
872
873 let result = format_document(request);
874 assert!(result.is_ok());
875 let res = result.unwrap();
876 assert_eq!(res.formatted_citations.len(), 1);
877 assert!(!res.formatted_citations[0].text.is_empty());
878 }
879
880 #[test]
881 fn format_document_uri_input_unresolved() {
882 let request = FormatDocumentRequest {
883 style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
884 style_overrides: None,
885 locale: None,
886 output_format: OutputFormatKind::Plain,
887 refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
888 citations: vec![],
889 bibliography_blocks: Vec::new(),
890 document_options: None,
891 nocite: vec![],
892 };
893
894 let result = format_document(request);
895 match result {
896 Err(FormatDocumentError::UnresolvedInput(_)) => {
897 }
899 _ => panic!("Expected UnresolvedInput error"),
900 }
901 }
902
903 struct MockResolver(Style);
905
906 impl citum_resolver_api::StyleResolver for MockResolver {
907 type Style = Style;
908 type Locale = citum_schema::locale::Locale;
909
910 fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
911 Ok(self.0.clone())
912 }
913
914 fn resolve_locale(
915 &self,
916 id: &str,
917 ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
918 Err(citum_schema::ResolverError::LocaleNotFound(
919 std::borrow::Cow::Owned(id.to_string()),
920 ))
921 }
922 }
923
924 #[test]
925 fn format_document_with_resolver_injects_style_for_id_input() {
926 let style = make_test_style();
927 let resolver = MockResolver(style);
928 let refs = make_test_bibliography();
929
930 let citation_occ = CitationOccurrence {
931 id: "c1".to_string(),
932 items: vec![CitationOccurrenceItem {
933 id: "smith2020".to_string(),
934 locator: None,
935 prefix: None,
936 suffix: None,
937 integral_name_state: None,
938 org_abbreviation_state: None,
939 }],
940 mode: None,
941 note_number: None,
942 suppress_author: None,
943 grouped: None,
944 prefix: None,
945 suffix: None,
946 sentence_start: None,
947 };
948
949 let request = FormatDocumentRequest {
950 style: StyleInput::Id("any-id".to_string()),
951 style_overrides: None,
952 locale: None,
953 output_format: OutputFormatKind::Plain,
954 refs,
955 citations: vec![citation_occ],
956 bibliography_blocks: Vec::new(),
957 document_options: None,
958 nocite: vec![],
959 };
960
961 match format_document(request.clone()) {
963 Err(FormatDocumentError::UnresolvedInput(_)) => {}
964 other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
965 }
966
967 let result = format_document_with_resolver(request, &resolver);
969 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
970 let res = result.unwrap();
971 assert_eq!(res.formatted_citations.len(), 1);
972 assert!(
973 !res.formatted_citations[0].text.is_empty(),
974 "formatted citation text should not be empty"
975 );
976 }
977
978 fn make_two_author_style() -> Style {
980 Style {
981 info: StyleInfo {
982 title: Some("Override Test Style".to_string()),
983 id: Some("override-test".into()),
984 ..Default::default()
985 },
986 options: Some(Config {
987 processing: Some(Processing::AuthorDate),
988 contributors: Some(ContributorConfig {
991 and: Some(AndOptions::Text),
992 ..Default::default()
993 }),
994 ..Default::default()
995 }),
996 citation: Some(CitationSpec {
997 template: Some(vec![
998 TemplateComponent::Contributor(TemplateContributor {
999 contributor: ContributorRole::Author,
1000 form: ContributorForm::Short,
1001 rendering: Rendering::default(),
1002 ..Default::default()
1003 }),
1004 TemplateComponent::Date(TemplateDate {
1005 date: TemplateDateVariable::Issued,
1006 form: DateForm::Year,
1007 rendering: Rendering {
1008 prefix: Some(", ".to_string()),
1009 ..Default::default()
1010 },
1011 ..Default::default()
1012 }),
1013 ]),
1014 wrap: Some(WrapPunctuation::Parentheses.into()),
1015 ..Default::default()
1016 }),
1017 ..Default::default()
1018 }
1019 }
1020
1021 fn make_two_author_refs() -> RefsInput {
1027 RefsInput::Yaml(
1028 r#"duo2024:
1029 class: monograph
1030 id: duo2024
1031 type: book
1032 title: Duo Work
1033 issued: "2024"
1034 author:
1035 - family: Smith
1036 given: Alice
1037 - family: Jones
1038 given: Bob
1039"#
1040 .to_string(),
1041 )
1042 }
1043
1044 fn cite(ref_id: &str) -> CitationOccurrence {
1046 CitationOccurrence {
1047 id: "c1".to_string(),
1048 items: vec![CitationOccurrenceItem {
1049 id: ref_id.to_string(),
1050 locator: None,
1051 prefix: None,
1052 suffix: None,
1053 integral_name_state: None,
1054 org_abbreviation_state: None,
1055 }],
1056 mode: None,
1057 note_number: None,
1058 suppress_author: None,
1059 grouped: None,
1060 prefix: None,
1061 suffix: None,
1062 sentence_start: None,
1063 }
1064 }
1065
1066 #[test]
1067 fn style_overrides_and_symbol_changes_rendered_output() {
1068 let base_style = make_two_author_style();
1069 let refs = make_two_author_refs();
1070
1071 let request_base = FormatDocumentRequest {
1073 style: StyleInput::Yaml("dummy".to_string()),
1074 style_overrides: None,
1075 locale: None,
1076 output_format: OutputFormatKind::Plain,
1077 refs: refs.clone(),
1078 citations: vec![cite("duo2024")],
1079 bibliography_blocks: Vec::new(),
1080 document_options: None,
1081 nocite: vec![],
1082 };
1083 let result_base = format_document_with_style(base_style.clone(), request_base).unwrap();
1084 let text_base = &result_base.formatted_citations[0].text;
1085 assert!(
1086 text_base.contains("and"),
1087 "base style should use text 'and' connector, got: {text_base:?}"
1088 );
1089
1090 let request_override = FormatDocumentRequest {
1092 style: StyleInput::Yaml("dummy".to_string()),
1093 style_overrides: Some("options:\n contributors:\n and: symbol\n".to_string()),
1094 locale: None,
1095 output_format: OutputFormatKind::Plain,
1096 refs,
1097 citations: vec![cite("duo2024")],
1098 bibliography_blocks: Vec::new(),
1099 document_options: None,
1100 nocite: vec![],
1101 };
1102 let result_override =
1103 format_document_with_style(base_style.clone(), request_override).unwrap();
1104 let text_override = &result_override.formatted_citations[0].text;
1105 assert!(
1106 text_override.contains('&'),
1107 "overridden style should use '&' connector, got: {text_override:?}"
1108 );
1109
1110 let base_and = base_style
1112 .options
1113 .as_ref()
1114 .and_then(|o| o.contributors.as_ref())
1115 .and_then(|c| c.and.as_ref());
1116 assert!(
1117 matches!(base_and, Some(&AndOptions::Text)),
1118 "base style must not be mutated; expected And::Text, got: {base_and:?}"
1119 );
1120 }
1121
1122 #[test]
1123 fn style_overrides_invalid_yaml_returns_parse_error() {
1124 let style = make_test_style();
1125 let refs = make_test_bibliography();
1126
1127 let request = FormatDocumentRequest {
1128 style: StyleInput::Yaml("dummy".to_string()),
1129 style_overrides: Some("{ unclosed yaml: [".to_string()),
1130 locale: None,
1131 output_format: OutputFormatKind::Plain,
1132 refs,
1133 citations: vec![],
1134 bibliography_blocks: Vec::new(),
1135 document_options: None,
1136 nocite: vec![],
1137 };
1138
1139 match format_document_with_style(style, request) {
1140 Err(FormatDocumentError::StyleParse(msg)) => {
1141 assert!(
1142 msg.contains("style_overrides"),
1143 "error message should mention style_overrides, got: {msg}"
1144 );
1145 }
1146 other => panic!("expected StyleParse error, got: {other:?}"),
1147 }
1148 }
1149
1150 #[test]
1151 fn apply_style_overrides_merges_option_field() {
1152 let mut style = make_test_style();
1153 apply_style_overrides(&mut style, "options:\n contributors:\n and: symbol\n")
1154 .expect("apply_style_overrides should succeed");
1155
1156 let and_option = style
1157 .options
1158 .as_ref()
1159 .and_then(|o| o.contributors.as_ref())
1160 .and_then(|c| c.and.as_ref());
1161 assert!(
1162 matches!(and_option, Some(&AndOptions::Symbol)),
1163 "expected And::Symbol after override, got: {and_option:?}"
1164 );
1165 }
1166
1167 fn make_integral_name_style() -> Style {
1173 use citum_schema::options::{
1174 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1175 };
1176 Style {
1177 info: StyleInfo {
1178 title: Some("Integral Name Memory Test".to_string()),
1179 id: Some("integral-name-memory-test".into()),
1180 ..Default::default()
1181 },
1182 options: Some(Config {
1183 processing: Some(Processing::AuthorDate),
1184 integral_name_memory: Some(IntegralNameMemoryConfig {
1185 scope: Some(IntegralNameScope::Document),
1186 contexts: Some(IntegralNameContexts::BodyAndNotes),
1187 subsequent_form: Some(SubsequentNameForm::Short),
1188 ..Default::default()
1189 }),
1190 ..Default::default()
1191 }),
1192 citation: Some(CitationSpec {
1193 integral: Some(Box::new(CitationSpec {
1194 template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1195 contributor: ContributorRole::Author,
1196 form: ContributorForm::Long,
1197 rendering: Rendering::default(),
1198 ..Default::default()
1199 })]),
1200 ..Default::default()
1201 })),
1202 template: Some(vec![
1203 TemplateComponent::Contributor(TemplateContributor {
1204 contributor: ContributorRole::Author,
1205 form: ContributorForm::Short,
1206 rendering: Rendering::default(),
1207 ..Default::default()
1208 }),
1209 TemplateComponent::Date(TemplateDate {
1210 date: TemplateDateVariable::Issued,
1211 form: DateForm::Year,
1212 rendering: Rendering::default(),
1213 ..Default::default()
1214 }),
1215 ]),
1216 wrap: Some(WrapPunctuation::Parentheses.into()),
1217 ..Default::default()
1218 }),
1219 ..Default::default()
1220 }
1221 }
1222
1223 fn make_smith_refs() -> RefsInput {
1224 RefsInput::Yaml(
1225 r#"smith2020:
1226 class: monograph
1227 id: smith2020
1228 type: book
1229 title: Smith Book
1230 issued: "2020"
1231 author:
1232 - family: Smith
1233 given: John
1234"#
1235 .to_string(),
1236 )
1237 }
1238
1239 fn make_integral_occ(id: &str, ref_id: &str) -> CitationOccurrence {
1240 CitationOccurrence {
1241 id: id.to_string(),
1242 items: vec![CitationOccurrenceItem {
1243 id: ref_id.to_string(),
1244 locator: None,
1245 prefix: None,
1246 suffix: None,
1247 integral_name_state: None,
1248 org_abbreviation_state: None,
1249 }],
1250 mode: Some(citum_schema::data::citation::CitationMode::Integral),
1251 note_number: None,
1252 suppress_author: None,
1253 grouped: None,
1254 prefix: None,
1255 suffix: None,
1256 sentence_start: None,
1257 }
1258 }
1259
1260 #[test]
1261 fn document_options_integral_name_memory_first_full_then_short() {
1262 use crate::processor::document::DocumentIntegralNameOverride;
1263
1264 let style = make_integral_name_style();
1265 let refs = make_smith_refs();
1266
1267 let request = FormatDocumentRequest {
1268 style: StyleInput::Yaml("dummy".to_string()),
1269 style_overrides: None,
1270 locale: None,
1271 output_format: OutputFormatKind::Plain,
1272 refs,
1273 citations: vec![
1274 make_integral_occ("c1", "smith2020"),
1275 make_integral_occ("c2", "smith2020"),
1276 ],
1277 bibliography_blocks: Vec::new(),
1278 document_options: Some(DocumentOptions {
1279 integral_name_memory: Some(DocumentIntegralNameOverride {
1280 enabled: Some(true),
1281 ..Default::default()
1282 }),
1283 ..Default::default()
1284 }),
1285 nocite: vec![],
1286 };
1287
1288 let result = format_document_with_style(style, request).expect("should render");
1289
1290 assert!(
1291 !result
1292 .warnings
1293 .iter()
1294 .any(|w| w.code == "integral_name_memory_not_applied"),
1295 "stale warning must not appear: {:?}",
1296 result.warnings
1297 );
1298 assert_eq!(
1299 result.formatted_citations[0].text, "John Smith",
1300 "first integral cite should render full name form"
1301 );
1302 assert_eq!(
1303 result.formatted_citations[1].text, "Smith",
1304 "second integral cite of same author should render short form"
1305 );
1306 }
1307
1308 #[test]
1309 fn document_options_integral_name_memory_disabled_keeps_full_form() {
1310 use crate::processor::document::DocumentIntegralNameOverride;
1311
1312 let style = make_integral_name_style();
1313 let refs = make_smith_refs();
1314
1315 let request = FormatDocumentRequest {
1316 style: StyleInput::Yaml("dummy".to_string()),
1317 style_overrides: None,
1318 locale: None,
1319 output_format: OutputFormatKind::Plain,
1320 refs,
1321 citations: vec![
1322 make_integral_occ("c1", "smith2020"),
1323 make_integral_occ("c2", "smith2020"),
1324 ],
1325 bibliography_blocks: Vec::new(),
1326 document_options: Some(DocumentOptions {
1327 integral_name_memory: Some(DocumentIntegralNameOverride {
1328 enabled: Some(false),
1329 ..Default::default()
1330 }),
1331 ..Default::default()
1332 }),
1333 nocite: vec![],
1334 };
1335
1336 let result = format_document_with_style(style, request).expect("should render");
1337
1338 assert_eq!(
1341 result.formatted_citations[0].text, "John Smith",
1342 "first integral cite: {}",
1343 result.formatted_citations[0].text
1344 );
1345 assert_eq!(
1346 result.formatted_citations[1].text, "John Smith",
1347 "second integral cite should also be full when memory is disabled"
1348 );
1349 }
1350
1351 #[test]
1352 fn style_native_integral_name_memory_applied_without_document_override() {
1353 let style = make_integral_name_style();
1356 let refs = make_smith_refs();
1357
1358 let request = FormatDocumentRequest {
1359 style: StyleInput::Yaml("dummy".to_string()),
1360 style_overrides: None,
1361 locale: None,
1362 output_format: OutputFormatKind::Plain,
1363 refs,
1364 citations: vec![
1365 make_integral_occ("c1", "smith2020"),
1366 make_integral_occ("c2", "smith2020"),
1367 ],
1368 bibliography_blocks: Vec::new(),
1369 document_options: None,
1370 nocite: vec![],
1371 };
1372
1373 let result = format_document_with_style(style, request).expect("should render");
1374
1375 assert_eq!(
1376 result.formatted_citations[0].text, "John Smith",
1377 "first integral cite should render full name form"
1378 );
1379 assert_eq!(
1380 result.formatted_citations[1].text, "Smith",
1381 "second integral cite should render short form from style-native config"
1382 );
1383 }
1384
1385 #[test]
1386 fn format_document_bibliography_blocks_ordered_with_dedup() {
1387 use citum_schema::grouping::CitedStatus;
1388 use citum_schema::grouping::{BibliographyGroup, GroupSelector};
1389
1390 let mut style = make_test_style();
1391 style.bibliography = Some(BibliographySpec {
1392 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1393 title: TitleType::Primary,
1394 ..Default::default()
1395 })]),
1396 ..Default::default()
1397 });
1398 let mut refs = Bibliography::new();
1399 refs.insert(
1400 "smith2020".to_string(),
1401 InputReference::Monograph(Box::new(Monograph {
1402 id: Some("smith2020".into()),
1403 r#type: MonographType::Book,
1404 title: Some(Title::Single("Sample Work".to_string())),
1405 issued: EdtfString("2020".to_string()),
1406 ..Default::default()
1407 })),
1408 );
1409 refs.insert(
1410 "jones2019".to_string(),
1411 InputReference::Monograph(Box::new(Monograph {
1412 id: Some("jones2019".into()),
1413 r#type: MonographType::Book,
1414 title: Some(Title::Single("Another Work".to_string())),
1415 issued: EdtfString("2019".to_string()),
1416 ..Default::default()
1417 })),
1418 );
1419
1420 let make_block = |id: &str| crate::BibliographyBlockRequest {
1421 id: id.to_string(),
1422 group: BibliographyGroup {
1423 id: id.to_string(),
1424 selector: GroupSelector {
1425 cited: Some(CitedStatus::Any),
1426 ..Default::default()
1427 },
1428 ..Default::default()
1429 },
1430 };
1431
1432 let request = FormatDocumentRequest {
1433 style: StyleInput::Yaml("dummy".to_string()),
1434 style_overrides: None,
1435 locale: None,
1436 output_format: OutputFormatKind::Plain,
1437 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1438 citations: vec![],
1439 bibliography_blocks: vec![make_block("block-a"), make_block("block-b")],
1440 document_options: None,
1441 nocite: vec![],
1442 };
1443
1444 let result = format_document_with_style(style, request).expect("should render");
1445
1446 assert_eq!(result.bibliography_blocks.len(), 2, "both blocks returned");
1447 assert_eq!(result.bibliography_blocks[0].id, "block-a");
1448 assert_eq!(result.bibliography_blocks[1].id, "block-b");
1449
1450 let block_a_count = result.bibliography_blocks[0].entries.len();
1451 let block_b_count = result.bibliography_blocks[1].entries.len();
1452
1453 assert_eq!(block_a_count, 2, "block-a captures both refs");
1454 assert_eq!(
1455 block_b_count, 0,
1456 "block-b is empty: dedup set prevents re-assignment from block-a"
1457 );
1458 }
1459
1460 #[test]
1465 fn nocite_ref_in_bibliography_not_in_formatted_citations() {
1466 let mut style = make_test_style();
1467 style.bibliography = Some(BibliographySpec {
1469 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1470 title: TitleType::Primary,
1471 ..Default::default()
1472 })]),
1473 ..Default::default()
1474 });
1475 let refs = make_test_bibliography(); let request = FormatDocumentRequest {
1478 style: StyleInput::Yaml("dummy".to_string()),
1479 style_overrides: None,
1480 locale: None,
1481 output_format: OutputFormatKind::Plain,
1482 refs,
1483 citations: vec![],
1484 bibliography_blocks: Vec::new(),
1485 document_options: None,
1486 nocite: vec!["smith2020".to_string()],
1487 };
1488
1489 let result = format_document_with_style(style, request).expect("should render");
1490
1491 assert_eq!(
1492 result.formatted_citations.len(),
1493 0,
1494 "nocite refs must not produce a formatted citation"
1495 );
1496 assert_eq!(
1497 result.bibliography.entries.len(),
1498 1,
1499 "nocite ref must appear in bibliography entries"
1500 );
1501 assert_eq!(
1502 result.bibliography.entries[0].id, "smith2020",
1503 "bibliography entry id should match nocite ref"
1504 );
1505 assert!(
1506 !result.bibliography.content.is_empty(),
1507 "bibliography content must be non-empty for nocite ref"
1508 );
1509 assert!(
1510 result.warnings.is_empty(),
1511 "no warnings expected: {:?}",
1512 result.warnings
1513 );
1514 }
1515
1516 #[test]
1519 fn nocite_missing_ref_emits_warning() {
1520 let style = make_test_style();
1521 let refs = make_test_bibliography();
1522
1523 let request = FormatDocumentRequest {
1524 style: StyleInput::Yaml("dummy".to_string()),
1525 style_overrides: None,
1526 locale: None,
1527 output_format: OutputFormatKind::Plain,
1528 refs,
1529 citations: vec![],
1530 bibliography_blocks: Vec::new(),
1531 document_options: None,
1532 nocite: vec!["does_not_exist".to_string()],
1533 };
1534
1535 let result = format_document_with_style(style, request).expect("should render");
1536
1537 assert_eq!(
1538 result.bibliography.entries.len(),
1539 0,
1540 "absent nocite ref must not produce a bibliography entry"
1541 );
1542 let warning = result
1543 .warnings
1544 .iter()
1545 .find(|w| w.code == "nocite_missing_ref")
1546 .expect("nocite_missing_ref warning should be emitted");
1547 assert_eq!(
1548 warning.ref_id.as_deref(),
1549 Some("does_not_exist"),
1550 "warning ref_id should name the absent nocite key"
1551 );
1552 }
1553
1554 #[test]
1557 fn nocite_ref_sorts_alongside_cited_ref() {
1558 let mut style = make_test_style();
1559 style.bibliography = Some(BibliographySpec {
1560 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1561 title: TitleType::Primary,
1562 ..Default::default()
1563 })]),
1564 ..Default::default()
1565 });
1566
1567 let citation_occ = CitationOccurrence {
1568 id: "c1".to_string(),
1569 items: vec![CitationOccurrenceItem {
1570 id: "duo2024".to_string(),
1571 locator: None,
1572 prefix: None,
1573 suffix: None,
1574 integral_name_state: None,
1575 org_abbreviation_state: None,
1576 }],
1577 mode: None,
1578 note_number: None,
1579 suppress_author: None,
1580 grouped: None,
1581 prefix: None,
1582 suffix: None,
1583 sentence_start: None,
1584 };
1585
1586 let combined_refs = RefsInput::Yaml(
1588 r#"duo2024:
1589 class: monograph
1590 id: duo2024
1591 type: book
1592 title: Duo Work
1593 issued: "2024"
1594 author:
1595 - family: Smith
1596 given: Alice
1597 - family: Jones
1598 given: Bob
1599smith2020:
1600 class: monograph
1601 id: smith2020
1602 type: book
1603 title: Smith Work
1604 issued: "2020"
1605 author:
1606 - family: Smith
1607 given: Alex
1608"#
1609 .to_string(),
1610 );
1611
1612 let request = FormatDocumentRequest {
1613 style: StyleInput::Yaml("dummy".to_string()),
1614 style_overrides: None,
1615 locale: None,
1616 output_format: OutputFormatKind::Plain,
1617 refs: combined_refs,
1618 citations: vec![citation_occ],
1619 bibliography_blocks: Vec::new(),
1620 document_options: None,
1621 nocite: vec!["smith2020".to_string()],
1622 };
1623
1624 let result = format_document_with_style(style, request).expect("should render");
1625
1626 assert_eq!(result.formatted_citations.len(), 1, "one in-text citation");
1627 assert_eq!(
1628 result.bibliography.entries.len(),
1629 2,
1630 "both cited and nocite refs must appear in the bibliography"
1631 );
1632 let ids: Vec<&str> = result
1633 .bibliography
1634 .entries
1635 .iter()
1636 .map(|e| e.id.as_str())
1637 .collect();
1638 assert!(
1639 ids.contains(&"duo2024"),
1640 "cited ref must be in bibliography: {ids:?}"
1641 );
1642 assert!(
1643 ids.contains(&"smith2020"),
1644 "nocite ref must be in bibliography: {ids:?}"
1645 );
1646 }
1647
1648 fn apa_style_path() -> String {
1649 use std::path::PathBuf;
1650 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1651 .parent()
1652 .unwrap()
1653 .parent()
1654 .unwrap()
1655 .join("styles/embedded/apa-7th.yaml")
1656 .to_str()
1657 .unwrap()
1658 .to_string()
1659 }
1660
1661 fn chicago_notes_path() -> String {
1662 use std::path::PathBuf;
1663 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1664 .parent()
1665 .unwrap()
1666 .parent()
1667 .unwrap()
1668 .join("styles/embedded/chicago-notes-18th.yaml")
1669 .to_str()
1670 .unwrap()
1671 .to_string()
1672 }
1673
1674 fn make_citation_occ(id: &str, ref_id: &str, mode: Option<CitationMode>) -> CitationOccurrence {
1675 CitationOccurrence {
1676 id: id.to_string(),
1677 items: vec![CitationOccurrenceItem {
1678 id: ref_id.to_string(),
1679 locator: None,
1680 prefix: None,
1681 suffix: None,
1682 integral_name_state: None,
1683 org_abbreviation_state: None,
1684 }],
1685 mode,
1686 note_number: None,
1687 suppress_author: None,
1688 grouped: None,
1689 prefix: None,
1690 suffix: None,
1691 sentence_start: None,
1692 }
1693 }
1694
1695 #[test]
1696 fn format_document_author_date_mixed_citation_modes_order_preserved() {
1697 let refs = RefsInput::Json(serde_json::json!({
1698 "smith2020": {
1699 "id": "smith2020",
1700 "class": "monograph",
1701 "type": "book",
1702 "title": "Sample Work",
1703 "author": [{"family": "Smith", "given": "John"}],
1704 "issued": "2020"
1705 }
1706 }));
1707
1708 let request = FormatDocumentRequest {
1709 style: StyleInput::Path(apa_style_path()),
1710 style_overrides: None,
1711 locale: None,
1712 output_format: OutputFormatKind::Plain,
1713 refs,
1714 citations: vec![
1715 make_citation_occ("cite-integral", "smith2020", Some(CitationMode::Integral)),
1716 make_citation_occ(
1717 "cite-non-integral",
1718 "smith2020",
1719 Some(CitationMode::NonIntegral),
1720 ),
1721 ],
1722 bibliography_blocks: Vec::new(),
1723 document_options: None,
1724 nocite: vec![],
1725 };
1726
1727 let result = format_document(request).expect("format_document should succeed");
1728
1729 assert_eq!(
1730 result.formatted_citations.len(),
1731 2,
1732 "both citations should be returned"
1733 );
1734 assert_eq!(
1735 result.formatted_citations[0].id, "cite-integral",
1736 "document order must be preserved"
1737 );
1738 assert_eq!(
1739 result.formatted_citations[1].id, "cite-non-integral",
1740 "document order must be preserved"
1741 );
1742
1743 let integral = &result.formatted_citations[0].text;
1744 let non_integral = &result.formatted_citations[1].text;
1745
1746 assert!(
1747 !integral.starts_with('('),
1748 "integral citation should place author name outside parentheses: {integral:?}"
1749 );
1750 assert!(
1751 integral.contains("Smith"),
1752 "integral citation should contain author name: {integral:?}"
1753 );
1754 assert!(
1755 non_integral.starts_with('('),
1756 "non-integral citation should be fully parenthetical: {non_integral:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn format_document_note_style_repeat_citations_produce_ibid() {
1762 let refs = RefsInput::Json(serde_json::json!({
1763 "smith1995": {
1764 "id": "smith1995",
1765 "class": "monograph",
1766 "type": "book",
1767 "title": "A Great Book",
1768 "author": [{"family": "Smith", "given": "John"}],
1769 "issued": "1995"
1770 }
1771 }));
1772
1773 let request = FormatDocumentRequest {
1774 style: StyleInput::Path(chicago_notes_path()),
1775 style_overrides: None,
1776 locale: None,
1777 output_format: OutputFormatKind::Plain,
1778 refs,
1779 citations: vec![
1780 make_citation_occ("cite-1", "smith1995", None),
1781 make_citation_occ("cite-2", "smith1995", None),
1782 make_citation_occ("cite-3", "smith1995", None),
1783 ],
1784 bibliography_blocks: Vec::new(),
1785 document_options: None,
1786 nocite: vec![],
1787 };
1788
1789 let result = format_document(request).expect("format_document should succeed");
1790
1791 assert_eq!(result.formatted_citations.len(), 3);
1792
1793 let first = &result.formatted_citations[0].text;
1794 let second = &result.formatted_citations[1].text;
1795 let third = &result.formatted_citations[2].text;
1796
1797 assert!(
1798 first.contains("Smith"),
1799 "first citation should render full form: {first:?}"
1800 );
1801 assert_eq!(
1802 second.as_str(),
1803 "Ibid.",
1804 "immediate repeat should render as ibid: {second:?}"
1805 );
1806 assert_eq!(
1807 third.as_str(),
1808 "Ibid.",
1809 "third repeat should also render as ibid: {third:?}"
1810 );
1811 }
1812
1813 #[test]
1814 fn format_document_annotations_appear_in_bibliography() {
1815 let refs = RefsInput::Json(serde_json::json!({
1816 "smith2020": {
1817 "id": "smith2020",
1818 "class": "monograph",
1819 "type": "book",
1820 "title": "Sample Work",
1821 "author": [{"family": "Smith", "given": "John"}],
1822 "issued": "2020"
1823 }
1824 }));
1825
1826 let mut annotations = HashMap::new();
1827 annotations.insert(
1828 "smith2020".to_string(),
1829 "Foundational work on the topic.".to_string(),
1830 );
1831
1832 let request = FormatDocumentRequest {
1833 style: StyleInput::Path(apa_style_path()),
1834 style_overrides: None,
1835 locale: None,
1836 output_format: OutputFormatKind::Plain,
1837 refs,
1838 citations: vec![make_citation_occ("cite-1", "smith2020", None)],
1839 bibliography_blocks: Vec::new(),
1840 document_options: Some(DocumentOptions {
1841 annotations: Some(annotations),
1842 ..Default::default()
1843 }),
1844 nocite: vec![],
1845 };
1846
1847 let result = format_document(request).expect("format_document should succeed");
1848
1849 assert!(
1850 result
1851 .bibliography
1852 .content
1853 .contains("Foundational work on the topic."),
1854 "annotation text should appear in bibliography output: {:?}",
1855 result.bibliography.content
1856 );
1857 }
1858}