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(thiserror::Error, Debug)]
92pub enum FormatDocumentError {
93 #[error("Unresolved style input: {0}")]
95 UnresolvedInput(String),
96 #[error("Style parse error: {0}")]
98 StyleParse(String),
99 #[error("Style path error: {0}")]
101 StylePath(String),
102 #[error("Refs input path error: {0}")]
104 RefsInputPath(String),
105 #[error("Refs input parse error: {0}")]
107 RefsInputParse(String),
108 #[error("Processing error: {0}")]
110 Processing(#[from] ProcessorError),
111 #[error("Style resolution error: {0}")]
113 StyleResolution(String),
114}
115
116pub fn apply_style_overrides(
130 style: &mut Style,
131 overlay_src: &str,
132) -> Result<(), FormatDocumentError> {
133 let overlay = Style::from_yaml_bytes(overlay_src.as_bytes()).map_err(|e| {
134 FormatDocumentError::StyleParse(format!("Failed to parse style_overrides: {e}"))
135 })?;
136 style.apply_overlay(&overlay);
137 style.apply_scoped_options();
138 Ok(())
139}
140
141pub fn format_document(
151 request: FormatDocumentRequest,
152) -> Result<FormatDocumentResult, FormatDocumentError> {
153 let style = request.style.resolve_local()?;
154 format_document_with_style(style, request)
155}
156
157pub fn format_document_with_resolver(
168 request: FormatDocumentRequest,
169 resolver: &citum_schema::StyleResolver,
170) -> Result<FormatDocumentResult, FormatDocumentError> {
171 let style = match &request.style {
172 StyleInput::Yaml(_) => request.style.resolve_local()?,
173 StyleInput::Id(value) | StyleInput::Uri(value) | StyleInput::Path(value) => resolver
174 .resolve_style(value)
175 .map_err(|e| FormatDocumentError::UnresolvedInput(e.to_string()))?,
176 };
177 let mut resolved = style
181 .try_into_resolved_with(Some(resolver))
182 .map_err(|e| FormatDocumentError::StyleResolution(e.to_string()))?;
183 resolved.extends = None;
184 format_document_with_style(resolved, request)
185}
186
187#[allow(
196 clippy::too_many_lines,
197 reason = "match arms grow one-to-one with format variants"
198)]
199pub fn format_document_with_style(
200 style: Style,
201 request: FormatDocumentRequest,
202) -> Result<FormatDocumentResult, FormatDocumentError> {
203 let mut warnings = Vec::new();
204
205 let mut style = style;
207 if let Some(src) = &request.style_overrides {
208 apply_style_overrides(&mut style, src)?;
209 }
210
211 if let Some(tag) = &request.locale
216 && !tag.is_empty()
217 && !tag.eq_ignore_ascii_case("en-us")
218 {
219 warnings.push(Warning {
220 level: WarningLevel::Warning,
221 code: "locale_fallback".to_string(),
222 citation_id: None,
223 ref_id: None,
224 message: format!(
225 "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
226 ),
227 });
228 }
229
230 let bibliography = request.refs.resolve_local()?;
231 let mut processor = Processor::new(style, bibliography);
232 warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
233 warnings.extend(unknown_reference_field_warnings(&processor.bibliography));
234 warnings.extend(unknown_enum_warnings(&processor));
235
236 if let Some(opts) = &request.document_options {
237 if let Some(new_proc) = processor
241 .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
242 {
243 processor = new_proc;
244 }
245 if let Some(show_semantics) = opts.show_semantics {
246 processor.show_semantics = show_semantics;
247 }
248 if let Some(inject_ast) = opts.inject_ast_indices {
249 processor.set_inject_ast_indices(inject_ast);
250 }
251 if let Some(abbr_map) = opts.abbreviation_map.clone() {
252 processor.abbreviation_map = Some(abbr_map);
253 }
254 }
255
256 let mut citations: Vec<Citation> = Vec::new();
261 for occ in request.citations {
262 let mut citation: Citation = occ.into();
263 citation.items.retain(|item| {
264 if processor.bibliography.contains_key(&item.id) {
265 true
266 } else {
267 warnings.push(Warning {
268 level: WarningLevel::Warning,
269 code: "missing_ref".to_string(),
270 citation_id: citation.id.clone(),
271 ref_id: Some(item.id.clone()),
272 message: format!("Reference '{}' not found in bibliography", item.id),
273 });
274 false
275 }
276 });
277 citations.push(citation);
278 }
279
280 processor.annotate_flat_integral_name_states(&mut citations);
284
285 let mut run = processor.begin_run();
289
290 let formatted_citations = match request.output_format {
292 OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations, &mut run)?,
293 OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations, &mut run)?,
294 OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations, &mut run)?,
295 OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations, &mut run)?,
296 OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations, &mut run)?,
297 OutputFormatKind::Markdown => format_by_kind::<Markdown>(&processor, &citations, &mut run)?,
298 };
299
300 let nocite_ids: Vec<String> = request
303 .nocite
304 .iter()
305 .filter_map(|id| {
306 if processor.bibliography.contains_key(id) {
307 Some(id.clone())
308 } else {
309 warnings.push(Warning {
310 level: WarningLevel::Warning,
311 code: "nocite_missing_ref".to_string(),
312 citation_id: None,
313 ref_id: Some(id.clone()),
314 message: format!("Nocite reference '{id}' not found in bibliography"),
315 });
316 None
317 }
318 })
319 .collect();
320 processor.register_nocite_ids(nocite_ids, &mut run);
321
322 let run = run.finalize();
325
326 let bibliography = match request.output_format {
328 OutputFormatKind::Plain => format_bibliography::<PlainText>(
329 &processor,
330 request.output_format,
331 request.document_options.as_ref(),
332 &run,
333 )?,
334 OutputFormatKind::Html => format_bibliography::<Html>(
335 &processor,
336 request.output_format,
337 request.document_options.as_ref(),
338 &run,
339 )?,
340 OutputFormatKind::Djot => format_bibliography::<Djot>(
341 &processor,
342 request.output_format,
343 request.document_options.as_ref(),
344 &run,
345 )?,
346 OutputFormatKind::Latex => format_bibliography::<Latex>(
347 &processor,
348 request.output_format,
349 request.document_options.as_ref(),
350 &run,
351 )?,
352 OutputFormatKind::Typst => format_bibliography::<Typst>(
353 &processor,
354 request.output_format,
355 request.document_options.as_ref(),
356 &run,
357 )?,
358 OutputFormatKind::Markdown => format_bibliography::<Markdown>(
359 &processor,
360 request.output_format,
361 request.document_options.as_ref(),
362 &run,
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 &run,
373 )?,
374 OutputFormatKind::Html => format_bibliography_blocks::<Html>(
375 &processor,
376 &request.bibliography_blocks,
377 request.document_options.as_ref(),
378 &run,
379 )?,
380 OutputFormatKind::Djot => format_bibliography_blocks::<Djot>(
381 &processor,
382 &request.bibliography_blocks,
383 request.document_options.as_ref(),
384 &run,
385 )?,
386 OutputFormatKind::Latex => format_bibliography_blocks::<Latex>(
387 &processor,
388 &request.bibliography_blocks,
389 request.document_options.as_ref(),
390 &run,
391 )?,
392 OutputFormatKind::Typst => format_bibliography_blocks::<Typst>(
393 &processor,
394 &request.bibliography_blocks,
395 request.document_options.as_ref(),
396 &run,
397 )?,
398 OutputFormatKind::Markdown => format_bibliography_blocks::<Markdown>(
399 &processor,
400 &request.bibliography_blocks,
401 request.document_options.as_ref(),
402 &run,
403 )?,
404 };
405
406 Ok(FormatDocumentResult {
407 formatted_citations,
408 bibliography,
409 bibliography_blocks,
410 warnings,
411 })
412}
413
414pub(crate) fn format_by_kind<F>(
416 processor: &Processor,
417 citations: &[Citation],
418 run: &mut crate::processor::RunState,
419) -> Result<Vec<FormattedCitation>, FormatDocumentError>
420where
421 F: OutputFormat<Output = String>,
422{
423 let texts = processor.process_citations_with_format::<F>(citations, run)?;
424
425 let formatted = citations
426 .iter()
427 .zip(texts.iter())
428 .map(|(citation, text)| {
429 let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
430 FormattedCitation {
431 id: citation.id.clone().unwrap_or_default(),
432 text: text.clone(),
433 ref_ids,
434 }
435 })
436 .collect();
437
438 Ok(formatted)
439}
440
441pub(crate) fn format_bibliography<F>(
449 processor: &Processor,
450 format_kind: OutputFormatKind,
451 doc_opts: Option<&DocumentOptions>,
452 run: &crate::processor::FinalizedRun,
453) -> Result<FormattedBibliography, FormatDocumentError>
454where
455 F: OutputFormat<Output = String>,
456{
457 let (annotations, annotation_style) = annotation_options(doc_opts);
458 let doc_bib = processor.render_document_bibliography::<F>(
459 true,
460 if annotations.is_empty() {
461 None
462 } else {
463 Some(&annotations)
464 },
465 annotation_style.as_ref(),
466 run,
467 );
468 let entries = doc_bib
469 .entries
470 .into_iter()
471 .map(|entry| {
472 proc_entry_to_bibliography_entry::<F>(
473 entry,
474 if annotations.is_empty() {
475 None
476 } else {
477 Some(&annotations)
478 },
479 annotation_style.as_ref(),
480 )
481 })
482 .collect();
483 Ok(FormattedBibliography {
484 format: format_kind,
485 content: doc_bib.content,
486 entries,
487 })
488}
489
490pub(crate) fn format_bibliography_blocks<F>(
495 processor: &Processor,
496 requests: &[super::BibliographyBlockRequest],
497 doc_opts: Option<&DocumentOptions>,
498 run: &crate::processor::FinalizedRun,
499) -> Result<Vec<super::FormattedBibliographyBlock>, FormatDocumentError>
500where
501 F: OutputFormat<Output = String>,
502{
503 if requests.is_empty() {
504 return Ok(Vec::new());
505 }
506
507 let (annotations, annotation_style) = annotation_options(doc_opts);
508 let groups: Vec<_> = requests.iter().map(|r| r.group.clone()).collect();
509 let rendered = processor.render_document_bibliography_blocks::<F>(
510 &groups,
511 if annotations.is_empty() {
512 None
513 } else {
514 Some(&annotations)
515 },
516 annotation_style.as_ref(),
517 run,
518 );
519
520 Ok(requests
521 .iter()
522 .zip(rendered)
523 .map(|(req, rg)| super::FormattedBibliographyBlock {
524 id: req.id.clone(),
525 heading: rg.heading,
526 content: rg.body,
527 entries: rg
528 .entries
529 .into_iter()
530 .map(|entry| {
531 proc_entry_to_bibliography_entry::<F>(
532 entry,
533 if annotations.is_empty() {
534 None
535 } else {
536 Some(&annotations)
537 },
538 annotation_style.as_ref(),
539 )
540 })
541 .collect(),
542 })
543 .collect())
544}
545
546fn annotation_options(
548 doc_opts: Option<&DocumentOptions>,
549) -> (HashMap<String, String>, Option<AnnotationStyle>) {
550 if let Some(opts) = doc_opts
551 && let Some(anns) = &opts.annotations
552 {
553 let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
554 format: fmt.clone(),
555 });
556 return (anns.clone(), style);
557 }
558 (HashMap::new(), None)
559}
560
561fn proc_entry_to_bibliography_entry<F>(
563 entry: crate::render::ProcEntry,
564 annotations: Option<&HashMap<String, String>>,
565 annotation_style: Option<&AnnotationStyle>,
566) -> BibliographyEntry
567where
568 F: OutputFormat<Output = String>,
569{
570 let text = crate::render::bibliography::refs_to_string_slice_with_format::<F>(
571 std::slice::from_ref(&entry),
572 annotations,
573 annotation_style,
574 );
575 let metadata = EntryMetadata {
576 author: entry.metadata.author.unwrap_or_default(),
577 year: entry.metadata.year.unwrap_or_default(),
578 title: entry.metadata.title.unwrap_or_default(),
579 };
580 BibliographyEntry {
581 id: entry.id,
582 text,
583 metadata,
584 }
585}
586
587#[cfg(test)]
588#[allow(
589 clippy::unwrap_used,
590 clippy::expect_used,
591 clippy::panic,
592 clippy::indexing_slicing,
593 reason = "test code uses assertions and panic"
594)]
595mod tests {
596 use super::*;
597 use crate::api::CitationOccurrenceItem;
598 use crate::reference::Bibliography;
599 use crate::{
600 Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
601 TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
602 WrapPunctuation,
603 };
604 use citum_schema::data::citation::CitationMode;
605 use citum_schema::options::{AndOptions, ContributorConfig};
606 use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
607 use citum_schema::template::{TemplateTitle, TitleType};
608 use citum_schema::{BibliographySpec, CitationSpec, StyleInfo};
609 use std::collections::HashMap;
610
611 fn make_test_style() -> Style {
612 Style {
613 info: StyleInfo {
614 title: Some("Test Style".to_string()),
615 id: Some("test".into()),
616 ..Default::default()
617 },
618 options: Some(Config {
619 processing: Some(Processing::AuthorDate),
620 ..Default::default()
621 }),
622 citation: Some(CitationSpec {
623 template: Some(vec![
624 TemplateComponent::Contributor(TemplateContributor {
625 contributor: ContributorRole::Author.into(),
626 form: ContributorForm::Short,
627 rendering: Rendering::default(),
628 ..Default::default()
629 }),
630 TemplateComponent::Date(TemplateDate {
631 date: TemplateDateVariable::Issued,
632 form: DateForm::Year,
633 rendering: Rendering::default(),
634 ..Default::default()
635 }),
636 ]),
637 wrap: Some(WrapPunctuation::Parentheses.into()),
638 ..Default::default()
639 }),
640 ..Default::default()
641 }
642 }
643
644 fn make_test_bibliography() -> RefsInput {
645 let mut refs = Bibliography::new();
646 refs.insert(
647 "smith2020".to_string(),
648 InputReference::Monograph(Box::new(Monograph {
649 id: Some("smith2020".into()),
650 r#type: MonographType::Book,
651 title: Some(Title::Single("Sample Work".to_string())),
652 issued: EdtfString("2020".to_string()),
653 ..Default::default()
654 })),
655 );
656 RefsInput::Json(serde_json::to_value(refs).unwrap())
657 }
658
659 fn make_markup_bibliography() -> RefsInput {
660 let mut refs = Bibliography::new();
661 refs.insert(
662 "art1".to_string(),
663 InputReference::Monograph(Box::new(Monograph {
664 id: Some("art1".into()),
665 r#type: MonographType::Book,
666 title: Some(Title::Single(
667 "_Homo sapiens_ and *modern* world".to_string(),
668 )),
669 issued: EdtfString("2023".to_string()),
670 ..Default::default()
671 })),
672 );
673 RefsInput::Json(serde_json::to_value(refs).unwrap())
674 }
675
676 #[test]
677 fn format_document_with_style_empty_citations() {
678 let style = make_test_style();
679 let refs = make_test_bibliography();
680 let request = FormatDocumentRequest {
681 style: StyleInput::Yaml("dummy".to_string()),
682 style_overrides: None,
683 locale: None,
684 output_format: OutputFormatKind::Plain,
685 refs,
686 citations: vec![],
687 bibliography_blocks: Vec::new(),
688 document_options: None,
689 nocite: vec![],
690 };
691
692 let result = format_document_with_style(style, request);
693 assert!(result.is_ok());
694 let res = result.unwrap();
695 assert_eq!(res.formatted_citations.len(), 0);
696 }
697
698 #[test]
699 fn format_document_html_bibliography_entries_preserve_inline_markup() {
700 let mut style = make_test_style();
701 style.bibliography = Some(BibliographySpec {
702 template: Some(vec![TemplateComponent::Title(TemplateTitle {
703 title: TitleType::Primary,
704 ..Default::default()
705 })]),
706 ..Default::default()
707 });
708
709 let request = FormatDocumentRequest {
710 style: StyleInput::Yaml("dummy".to_string()),
711 style_overrides: None,
712 locale: None,
713 output_format: OutputFormatKind::Html,
714 refs: make_markup_bibliography(),
715 citations: vec![],
716 bibliography_blocks: Vec::new(),
717 document_options: None,
718 nocite: vec!["art1".to_string()],
721 };
722
723 let result = format_document_with_style(style, request).expect("should render");
724
725 assert_eq!(
726 result.bibliography.entries[0].text, result.bibliography.content,
727 "single-entry bibliography should mirror the full bibliography payload"
728 );
729 assert!(
730 result.bibliography.entries[0].text.contains(
731 "<span class=\"citum-title\"><em>Homo sapiens</em> and <b>modern</b> world</span>"
732 ),
733 "per-entry HTML should preserve inline markup for Djot-bearing titles"
734 );
735 }
736
737 #[test]
738 fn format_document_missing_ref_warning() {
739 let style = make_test_style();
740 let refs = make_test_bibliography();
741
742 let citation_occ = CitationOccurrence {
743 id: "cite1".to_string(),
744 items: vec![CitationOccurrenceItem {
745 id: "unknown_ref".to_string(),
746 locator: None,
747 prefix: None,
748 suffix: None,
749 integral_name_state: None,
750 org_abbreviation_state: None,
751 }],
752 mode: None,
753 note_number: None,
754 suppress_author: None,
755 grouped: None,
756 prefix: None,
757 suffix: None,
758 sentence_start: None,
759 };
760
761 let request = FormatDocumentRequest {
762 style: StyleInput::Yaml("dummy".to_string()),
763 style_overrides: None,
764 locale: None,
765 output_format: OutputFormatKind::Plain,
766 refs,
767 citations: vec![citation_occ],
768 bibliography_blocks: Vec::new(),
769 document_options: None,
770 nocite: vec![],
771 };
772
773 let result = format_document_with_style(style, request);
774 assert!(result.is_ok());
775 let res = result.unwrap();
776 assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
777 }
778
779 #[test]
780 fn format_document_unknown_reference_class_warning() {
781 let style = make_test_style();
782 let mut refs = Bibliography::new();
783 let unknown_ref: InputReference = serde_json::from_str(
784 r#"{
785 "class": "dance-performance",
786 "id": "pina2011",
787 "title": "Pina",
788 "issued": "2011",
789 "venue": "Berlin"
790 }"#,
791 )
792 .expect("unknown class should parse through the compatibility path");
793 refs.insert("pina2011".to_string(), unknown_ref);
794
795 let citation_occ = CitationOccurrence {
796 id: "cite1".to_string(),
797 items: vec![CitationOccurrenceItem {
798 id: "pina2011".to_string(),
799 locator: None,
800 prefix: None,
801 suffix: None,
802 integral_name_state: None,
803 org_abbreviation_state: None,
804 }],
805 mode: None,
806 note_number: None,
807 suppress_author: None,
808 grouped: None,
809 prefix: None,
810 suffix: None,
811 sentence_start: None,
812 };
813
814 let request = FormatDocumentRequest {
815 style: StyleInput::Yaml("dummy".to_string()),
816 style_overrides: None,
817 locale: None,
818 output_format: OutputFormatKind::Plain,
819 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
820 citations: vec![citation_occ],
821 bibliography_blocks: Vec::new(),
822 document_options: None,
823 nocite: vec![],
824 };
825
826 let result = format_document_with_style(style, request).unwrap();
827 let warning = result
828 .warnings
829 .iter()
830 .find(|w| w.code == "unknown_reference_class")
831 .expect("unknown class warning should be emitted");
832 assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
833 assert!(warning.message.contains("dance-performance"));
834 }
835
836 #[test]
837 fn format_document_yaml_style_input() {
838 let style = make_test_style();
839 let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
840
841 let mut refs = Bibliography::new();
842 refs.insert(
843 "test2024".to_string(),
844 InputReference::Monograph(Box::new(Monograph {
845 id: Some("test2024".into()),
846 r#type: MonographType::Book,
847 title: Some(Title::Single("Test Work".to_string())),
848 issued: EdtfString("2024".to_string()),
849 ..Default::default()
850 })),
851 );
852
853 let citation_occ = CitationOccurrence {
854 id: "c1".to_string(),
855 items: vec![CitationOccurrenceItem {
856 id: "test2024".to_string(),
857 locator: None,
858 prefix: None,
859 suffix: None,
860 integral_name_state: None,
861 org_abbreviation_state: None,
862 }],
863 mode: None,
864 note_number: None,
865 suppress_author: None,
866 grouped: None,
867 prefix: None,
868 suffix: None,
869 sentence_start: None,
870 };
871
872 let request = FormatDocumentRequest {
873 style: StyleInput::Yaml(yaml_style),
874 style_overrides: None,
875 locale: None,
876 output_format: OutputFormatKind::Plain,
877 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
878 citations: vec![citation_occ],
879 bibliography_blocks: Vec::new(),
880 document_options: None,
881 nocite: vec![],
882 };
883
884 let result = format_document(request);
885 assert!(result.is_ok());
886 let res = result.unwrap();
887 assert_eq!(res.formatted_citations.len(), 1);
888 assert!(!res.formatted_citations[0].text.is_empty());
889 }
890
891 #[test]
892 fn format_document_uri_input_unresolved() {
893 let request = FormatDocumentRequest {
894 style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
895 style_overrides: None,
896 locale: None,
897 output_format: OutputFormatKind::Plain,
898 refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
899 citations: vec![],
900 bibliography_blocks: Vec::new(),
901 document_options: None,
902 nocite: vec![],
903 };
904
905 let result = format_document(request);
906 match result {
907 Err(FormatDocumentError::UnresolvedInput(_)) => {
908 }
910 _ => panic!("Expected UnresolvedInput error"),
911 }
912 }
913
914 struct MockResolver(Style);
916
917 impl citum_resolver_api::StyleResolver for MockResolver {
918 type Style = Style;
919 type Locale = citum_schema::locale::Locale;
920
921 fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
922 Ok(self.0.clone())
923 }
924
925 fn resolve_locale(
926 &self,
927 id: &str,
928 ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
929 Err(citum_schema::ResolverError::LocaleNotFound(
930 std::borrow::Cow::Owned(id.to_string()),
931 ))
932 }
933 }
934
935 #[test]
936 fn format_document_with_resolver_injects_style_for_id_input() {
937 let style = make_test_style();
938 let resolver = MockResolver(style);
939 let refs = make_test_bibliography();
940
941 let citation_occ = CitationOccurrence {
942 id: "c1".to_string(),
943 items: vec![CitationOccurrenceItem {
944 id: "smith2020".to_string(),
945 locator: None,
946 prefix: None,
947 suffix: None,
948 integral_name_state: None,
949 org_abbreviation_state: None,
950 }],
951 mode: None,
952 note_number: None,
953 suppress_author: None,
954 grouped: None,
955 prefix: None,
956 suffix: None,
957 sentence_start: None,
958 };
959
960 let request = FormatDocumentRequest {
961 style: StyleInput::Id("any-id".to_string()),
962 style_overrides: None,
963 locale: None,
964 output_format: OutputFormatKind::Plain,
965 refs,
966 citations: vec![citation_occ],
967 bibliography_blocks: Vec::new(),
968 document_options: None,
969 nocite: vec![],
970 };
971
972 match format_document(request.clone()) {
974 Err(FormatDocumentError::UnresolvedInput(_)) => {}
975 other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
976 }
977
978 let result = format_document_with_resolver(request, &resolver);
980 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
981 let res = result.unwrap();
982 assert_eq!(res.formatted_citations.len(), 1);
983 assert!(
984 !res.formatted_citations[0].text.is_empty(),
985 "formatted citation text should not be empty"
986 );
987 }
988
989 fn make_two_author_style() -> Style {
991 Style {
992 info: StyleInfo {
993 title: Some("Override Test Style".to_string()),
994 id: Some("override-test".into()),
995 ..Default::default()
996 },
997 options: Some(Config {
998 processing: Some(Processing::AuthorDate),
999 contributors: Some(ContributorConfig {
1002 and: Some(AndOptions::Text),
1003 ..Default::default()
1004 }),
1005 ..Default::default()
1006 }),
1007 citation: Some(CitationSpec {
1008 template: Some(vec![
1009 TemplateComponent::Contributor(TemplateContributor {
1010 contributor: ContributorRole::Author.into(),
1011 form: ContributorForm::Short,
1012 rendering: Rendering::default(),
1013 ..Default::default()
1014 }),
1015 TemplateComponent::Date(TemplateDate {
1016 date: TemplateDateVariable::Issued,
1017 form: DateForm::Year,
1018 rendering: Rendering {
1019 prefix: Some(", ".to_string()),
1020 ..Default::default()
1021 },
1022 ..Default::default()
1023 }),
1024 ]),
1025 wrap: Some(WrapPunctuation::Parentheses.into()),
1026 ..Default::default()
1027 }),
1028 ..Default::default()
1029 }
1030 }
1031
1032 fn make_two_author_refs() -> RefsInput {
1038 RefsInput::Yaml(
1039 r#"duo2024:
1040 class: monograph
1041 id: duo2024
1042 type: book
1043 title: Duo Work
1044 issued: "2024"
1045 author:
1046 - family: Smith
1047 given: Alice
1048 - family: Jones
1049 given: Bob
1050"#
1051 .to_string(),
1052 )
1053 }
1054
1055 fn cite(ref_id: &str) -> CitationOccurrence {
1057 CitationOccurrence {
1058 id: "c1".to_string(),
1059 items: vec![CitationOccurrenceItem {
1060 id: ref_id.to_string(),
1061 locator: None,
1062 prefix: None,
1063 suffix: None,
1064 integral_name_state: None,
1065 org_abbreviation_state: None,
1066 }],
1067 mode: None,
1068 note_number: None,
1069 suppress_author: None,
1070 grouped: None,
1071 prefix: None,
1072 suffix: None,
1073 sentence_start: None,
1074 }
1075 }
1076
1077 #[test]
1078 fn style_overrides_and_symbol_changes_rendered_output() {
1079 let base_style = make_two_author_style();
1080 let refs = make_two_author_refs();
1081
1082 let request_base = FormatDocumentRequest {
1084 style: StyleInput::Yaml("dummy".to_string()),
1085 style_overrides: None,
1086 locale: None,
1087 output_format: OutputFormatKind::Plain,
1088 refs: refs.clone(),
1089 citations: vec![cite("duo2024")],
1090 bibliography_blocks: Vec::new(),
1091 document_options: None,
1092 nocite: vec![],
1093 };
1094 let result_base = format_document_with_style(base_style.clone(), request_base).unwrap();
1095 let text_base = &result_base.formatted_citations[0].text;
1096 assert!(
1097 text_base.contains("and"),
1098 "base style should use text 'and' connector, got: {text_base:?}"
1099 );
1100
1101 let request_override = FormatDocumentRequest {
1103 style: StyleInput::Yaml("dummy".to_string()),
1104 style_overrides: Some("options:\n contributors:\n and: symbol\n".to_string()),
1105 locale: None,
1106 output_format: OutputFormatKind::Plain,
1107 refs,
1108 citations: vec![cite("duo2024")],
1109 bibliography_blocks: Vec::new(),
1110 document_options: None,
1111 nocite: vec![],
1112 };
1113 let result_override =
1114 format_document_with_style(base_style.clone(), request_override).unwrap();
1115 let text_override = &result_override.formatted_citations[0].text;
1116 assert!(
1117 text_override.contains('&'),
1118 "overridden style should use '&' connector, got: {text_override:?}"
1119 );
1120
1121 let base_and = base_style
1123 .options
1124 .as_ref()
1125 .and_then(|o| o.contributors.as_ref())
1126 .and_then(|c| c.and.as_ref());
1127 assert!(
1128 matches!(base_and, Some(&AndOptions::Text)),
1129 "base style must not be mutated; expected And::Text, got: {base_and:?}"
1130 );
1131 }
1132
1133 #[test]
1134 fn style_overrides_invalid_yaml_returns_parse_error() {
1135 let style = make_test_style();
1136 let refs = make_test_bibliography();
1137
1138 let request = FormatDocumentRequest {
1139 style: StyleInput::Yaml("dummy".to_string()),
1140 style_overrides: Some("{ unclosed yaml: [".to_string()),
1141 locale: None,
1142 output_format: OutputFormatKind::Plain,
1143 refs,
1144 citations: vec![],
1145 bibliography_blocks: Vec::new(),
1146 document_options: None,
1147 nocite: vec![],
1148 };
1149
1150 match format_document_with_style(style, request) {
1151 Err(FormatDocumentError::StyleParse(msg)) => {
1152 assert!(
1153 msg.contains("style_overrides"),
1154 "error message should mention style_overrides, got: {msg}"
1155 );
1156 }
1157 other => panic!("expected StyleParse error, got: {other:?}"),
1158 }
1159 }
1160
1161 #[test]
1162 fn apply_style_overrides_merges_option_field() {
1163 let mut style = make_test_style();
1164 apply_style_overrides(&mut style, "options:\n contributors:\n and: symbol\n")
1165 .expect("apply_style_overrides should succeed");
1166
1167 let and_option = style
1168 .options
1169 .as_ref()
1170 .and_then(|o| o.contributors.as_ref())
1171 .and_then(|c| c.and.as_ref());
1172 assert!(
1173 matches!(and_option, Some(&AndOptions::Symbol)),
1174 "expected And::Symbol after override, got: {and_option:?}"
1175 );
1176 }
1177
1178 fn make_integral_name_style() -> Style {
1184 use citum_schema::options::{
1185 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1186 };
1187 Style {
1188 info: StyleInfo {
1189 title: Some("Integral Name Memory Test".to_string()),
1190 id: Some("integral-name-memory-test".into()),
1191 ..Default::default()
1192 },
1193 options: Some(Config {
1194 processing: Some(Processing::AuthorDate),
1195 integral_name_memory: Some(IntegralNameMemoryConfig {
1196 scope: Some(IntegralNameScope::Document),
1197 contexts: Some(IntegralNameContexts::BodyAndNotes),
1198 subsequent_form: Some(SubsequentNameForm::Short),
1199 ..Default::default()
1200 }),
1201 ..Default::default()
1202 }),
1203 citation: Some(CitationSpec {
1204 integral: Some(Box::new(CitationSpec {
1205 template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1206 contributor: ContributorRole::Author.into(),
1207 form: ContributorForm::Long,
1208 rendering: Rendering::default(),
1209 ..Default::default()
1210 })]),
1211 ..Default::default()
1212 })),
1213 template: Some(vec![
1214 TemplateComponent::Contributor(TemplateContributor {
1215 contributor: ContributorRole::Author.into(),
1216 form: ContributorForm::Short,
1217 rendering: Rendering::default(),
1218 ..Default::default()
1219 }),
1220 TemplateComponent::Date(TemplateDate {
1221 date: TemplateDateVariable::Issued,
1222 form: DateForm::Year,
1223 rendering: Rendering::default(),
1224 ..Default::default()
1225 }),
1226 ]),
1227 wrap: Some(WrapPunctuation::Parentheses.into()),
1228 ..Default::default()
1229 }),
1230 ..Default::default()
1231 }
1232 }
1233
1234 fn make_smith_refs() -> RefsInput {
1235 RefsInput::Yaml(
1236 r#"smith2020:
1237 class: monograph
1238 id: smith2020
1239 type: book
1240 title: Smith Book
1241 issued: "2020"
1242 author:
1243 - family: Smith
1244 given: John
1245"#
1246 .to_string(),
1247 )
1248 }
1249
1250 fn make_integral_occ(id: &str, ref_id: &str) -> CitationOccurrence {
1251 CitationOccurrence {
1252 id: id.to_string(),
1253 items: vec![CitationOccurrenceItem {
1254 id: ref_id.to_string(),
1255 locator: None,
1256 prefix: None,
1257 suffix: None,
1258 integral_name_state: None,
1259 org_abbreviation_state: None,
1260 }],
1261 mode: Some(citum_schema::data::citation::CitationMode::Integral),
1262 note_number: None,
1263 suppress_author: None,
1264 grouped: None,
1265 prefix: None,
1266 suffix: None,
1267 sentence_start: None,
1268 }
1269 }
1270
1271 #[test]
1272 fn document_options_integral_name_memory_first_full_then_short() {
1273 use crate::processor::document::DocumentIntegralNameOverride;
1274
1275 let style = make_integral_name_style();
1276 let refs = make_smith_refs();
1277
1278 let request = FormatDocumentRequest {
1279 style: StyleInput::Yaml("dummy".to_string()),
1280 style_overrides: None,
1281 locale: None,
1282 output_format: OutputFormatKind::Plain,
1283 refs,
1284 citations: vec![
1285 make_integral_occ("c1", "smith2020"),
1286 make_integral_occ("c2", "smith2020"),
1287 ],
1288 bibliography_blocks: Vec::new(),
1289 document_options: Some(DocumentOptions {
1290 integral_name_memory: Some(DocumentIntegralNameOverride {
1291 enabled: Some(true),
1292 ..Default::default()
1293 }),
1294 ..Default::default()
1295 }),
1296 nocite: vec![],
1297 };
1298
1299 let result = format_document_with_style(style, request).expect("should render");
1300
1301 assert!(
1302 !result
1303 .warnings
1304 .iter()
1305 .any(|w| w.code == "integral_name_memory_not_applied"),
1306 "stale warning must not appear: {:?}",
1307 result.warnings
1308 );
1309 assert_eq!(
1310 result.formatted_citations[0].text, "John Smith",
1311 "first integral cite should render full name form"
1312 );
1313 assert_eq!(
1314 result.formatted_citations[1].text, "Smith",
1315 "second integral cite of same author should render short form"
1316 );
1317 }
1318
1319 #[test]
1320 fn document_options_integral_name_memory_disabled_keeps_full_form() {
1321 use crate::processor::document::DocumentIntegralNameOverride;
1322
1323 let style = make_integral_name_style();
1324 let refs = make_smith_refs();
1325
1326 let request = FormatDocumentRequest {
1327 style: StyleInput::Yaml("dummy".to_string()),
1328 style_overrides: None,
1329 locale: None,
1330 output_format: OutputFormatKind::Plain,
1331 refs,
1332 citations: vec![
1333 make_integral_occ("c1", "smith2020"),
1334 make_integral_occ("c2", "smith2020"),
1335 ],
1336 bibliography_blocks: Vec::new(),
1337 document_options: Some(DocumentOptions {
1338 integral_name_memory: Some(DocumentIntegralNameOverride {
1339 enabled: Some(false),
1340 ..Default::default()
1341 }),
1342 ..Default::default()
1343 }),
1344 nocite: vec![],
1345 };
1346
1347 let result = format_document_with_style(style, request).expect("should render");
1348
1349 assert_eq!(
1352 result.formatted_citations[0].text, "John Smith",
1353 "first integral cite: {}",
1354 result.formatted_citations[0].text
1355 );
1356 assert_eq!(
1357 result.formatted_citations[1].text, "John Smith",
1358 "second integral cite should also be full when memory is disabled"
1359 );
1360 }
1361
1362 #[test]
1363 fn style_native_integral_name_memory_applied_without_document_override() {
1364 let style = make_integral_name_style();
1367 let refs = make_smith_refs();
1368
1369 let request = FormatDocumentRequest {
1370 style: StyleInput::Yaml("dummy".to_string()),
1371 style_overrides: None,
1372 locale: None,
1373 output_format: OutputFormatKind::Plain,
1374 refs,
1375 citations: vec![
1376 make_integral_occ("c1", "smith2020"),
1377 make_integral_occ("c2", "smith2020"),
1378 ],
1379 bibliography_blocks: Vec::new(),
1380 document_options: None,
1381 nocite: vec![],
1382 };
1383
1384 let result = format_document_with_style(style, request).expect("should render");
1385
1386 assert_eq!(
1387 result.formatted_citations[0].text, "John Smith",
1388 "first integral cite should render full name form"
1389 );
1390 assert_eq!(
1391 result.formatted_citations[1].text, "Smith",
1392 "second integral cite should render short form from style-native config"
1393 );
1394 }
1395
1396 #[test]
1397 fn format_document_bibliography_blocks_ordered_with_dedup() {
1398 use citum_schema::grouping::CitedStatus;
1399 use citum_schema::grouping::{BibliographyGroup, GroupSelector};
1400
1401 let mut style = make_test_style();
1402 style.bibliography = Some(BibliographySpec {
1403 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1404 title: TitleType::Primary,
1405 ..Default::default()
1406 })]),
1407 ..Default::default()
1408 });
1409 let mut refs = Bibliography::new();
1410 refs.insert(
1411 "smith2020".to_string(),
1412 InputReference::Monograph(Box::new(Monograph {
1413 id: Some("smith2020".into()),
1414 r#type: MonographType::Book,
1415 title: Some(Title::Single("Sample Work".to_string())),
1416 issued: EdtfString("2020".to_string()),
1417 ..Default::default()
1418 })),
1419 );
1420 refs.insert(
1421 "jones2019".to_string(),
1422 InputReference::Monograph(Box::new(Monograph {
1423 id: Some("jones2019".into()),
1424 r#type: MonographType::Book,
1425 title: Some(Title::Single("Another Work".to_string())),
1426 issued: EdtfString("2019".to_string()),
1427 ..Default::default()
1428 })),
1429 );
1430
1431 let make_block = |id: &str| crate::BibliographyBlockRequest {
1432 id: id.to_string(),
1433 group: BibliographyGroup {
1434 id: id.to_string(),
1435 selector: GroupSelector {
1436 cited: Some(CitedStatus::Any),
1437 ..Default::default()
1438 },
1439 ..Default::default()
1440 },
1441 };
1442
1443 let request = FormatDocumentRequest {
1444 style: StyleInput::Yaml("dummy".to_string()),
1445 style_overrides: None,
1446 locale: None,
1447 output_format: OutputFormatKind::Plain,
1448 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1449 citations: vec![],
1450 bibliography_blocks: vec![make_block("block-a"), make_block("block-b")],
1451 document_options: None,
1452 nocite: vec![],
1453 };
1454
1455 let result = format_document_with_style(style, request).expect("should render");
1456
1457 assert_eq!(result.bibliography_blocks.len(), 2, "both blocks returned");
1458 assert_eq!(result.bibliography_blocks[0].id, "block-a");
1459 assert_eq!(result.bibliography_blocks[1].id, "block-b");
1460
1461 let block_a_count = result.bibliography_blocks[0].entries.len();
1462 let block_b_count = result.bibliography_blocks[1].entries.len();
1463
1464 assert_eq!(block_a_count, 2, "block-a captures both refs");
1465 assert_eq!(
1466 block_b_count, 0,
1467 "block-b is empty: dedup set prevents re-assignment from block-a"
1468 );
1469 }
1470
1471 #[test]
1476 fn nocite_ref_in_bibliography_not_in_formatted_citations() {
1477 let mut style = make_test_style();
1478 style.bibliography = Some(BibliographySpec {
1480 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1481 title: TitleType::Primary,
1482 ..Default::default()
1483 })]),
1484 ..Default::default()
1485 });
1486 let refs = make_test_bibliography(); let request = FormatDocumentRequest {
1489 style: StyleInput::Yaml("dummy".to_string()),
1490 style_overrides: None,
1491 locale: None,
1492 output_format: OutputFormatKind::Plain,
1493 refs,
1494 citations: vec![],
1495 bibliography_blocks: Vec::new(),
1496 document_options: None,
1497 nocite: vec!["smith2020".to_string()],
1498 };
1499
1500 let result = format_document_with_style(style, request).expect("should render");
1501
1502 assert_eq!(
1503 result.formatted_citations.len(),
1504 0,
1505 "nocite refs must not produce a formatted citation"
1506 );
1507 assert_eq!(
1508 result.bibliography.entries.len(),
1509 1,
1510 "nocite ref must appear in bibliography entries"
1511 );
1512 assert_eq!(
1513 result.bibliography.entries[0].id, "smith2020",
1514 "bibliography entry id should match nocite ref"
1515 );
1516 assert!(
1517 !result.bibliography.content.is_empty(),
1518 "bibliography content must be non-empty for nocite ref"
1519 );
1520 assert!(
1521 result.warnings.is_empty(),
1522 "no warnings expected: {:?}",
1523 result.warnings
1524 );
1525 }
1526
1527 #[test]
1530 fn nocite_missing_ref_emits_warning() {
1531 let style = make_test_style();
1532 let refs = make_test_bibliography();
1533
1534 let request = FormatDocumentRequest {
1535 style: StyleInput::Yaml("dummy".to_string()),
1536 style_overrides: None,
1537 locale: None,
1538 output_format: OutputFormatKind::Plain,
1539 refs,
1540 citations: vec![],
1541 bibliography_blocks: Vec::new(),
1542 document_options: None,
1543 nocite: vec!["does_not_exist".to_string()],
1544 };
1545
1546 let result = format_document_with_style(style, request).expect("should render");
1547
1548 assert_eq!(
1549 result.bibliography.entries.len(),
1550 0,
1551 "absent nocite ref must not produce a bibliography entry"
1552 );
1553 let warning = result
1554 .warnings
1555 .iter()
1556 .find(|w| w.code == "nocite_missing_ref")
1557 .expect("nocite_missing_ref warning should be emitted");
1558 assert_eq!(
1559 warning.ref_id.as_deref(),
1560 Some("does_not_exist"),
1561 "warning ref_id should name the absent nocite key"
1562 );
1563 }
1564
1565 #[test]
1568 fn nocite_ref_sorts_alongside_cited_ref() {
1569 let mut style = make_test_style();
1570 style.bibliography = Some(BibliographySpec {
1571 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1572 title: TitleType::Primary,
1573 ..Default::default()
1574 })]),
1575 ..Default::default()
1576 });
1577
1578 let citation_occ = CitationOccurrence {
1579 id: "c1".to_string(),
1580 items: vec![CitationOccurrenceItem {
1581 id: "duo2024".to_string(),
1582 locator: None,
1583 prefix: None,
1584 suffix: None,
1585 integral_name_state: None,
1586 org_abbreviation_state: None,
1587 }],
1588 mode: None,
1589 note_number: None,
1590 suppress_author: None,
1591 grouped: None,
1592 prefix: None,
1593 suffix: None,
1594 sentence_start: None,
1595 };
1596
1597 let combined_refs = RefsInput::Yaml(
1599 r#"duo2024:
1600 class: monograph
1601 id: duo2024
1602 type: book
1603 title: Duo Work
1604 issued: "2024"
1605 author:
1606 - family: Smith
1607 given: Alice
1608 - family: Jones
1609 given: Bob
1610smith2020:
1611 class: monograph
1612 id: smith2020
1613 type: book
1614 title: Smith Work
1615 issued: "2020"
1616 author:
1617 - family: Smith
1618 given: Alex
1619"#
1620 .to_string(),
1621 );
1622
1623 let request = FormatDocumentRequest {
1624 style: StyleInput::Yaml("dummy".to_string()),
1625 style_overrides: None,
1626 locale: None,
1627 output_format: OutputFormatKind::Plain,
1628 refs: combined_refs,
1629 citations: vec![citation_occ],
1630 bibliography_blocks: Vec::new(),
1631 document_options: None,
1632 nocite: vec!["smith2020".to_string()],
1633 };
1634
1635 let result = format_document_with_style(style, request).expect("should render");
1636
1637 assert_eq!(result.formatted_citations.len(), 1, "one in-text citation");
1638 assert_eq!(
1639 result.bibliography.entries.len(),
1640 2,
1641 "both cited and nocite refs must appear in the bibliography"
1642 );
1643 let ids: Vec<&str> = result
1644 .bibliography
1645 .entries
1646 .iter()
1647 .map(|e| e.id.as_str())
1648 .collect();
1649 assert!(
1650 ids.contains(&"duo2024"),
1651 "cited ref must be in bibliography: {ids:?}"
1652 );
1653 assert!(
1654 ids.contains(&"smith2020"),
1655 "nocite ref must be in bibliography: {ids:?}"
1656 );
1657 }
1658
1659 fn apa_style_path() -> String {
1660 use std::path::PathBuf;
1661 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1662 .parent()
1663 .unwrap()
1664 .parent()
1665 .unwrap()
1666 .join("styles/embedded/apa-7th.yaml")
1667 .to_str()
1668 .unwrap()
1669 .to_string()
1670 }
1671
1672 fn chicago_notes_path() -> String {
1673 use std::path::PathBuf;
1674 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1675 .parent()
1676 .unwrap()
1677 .parent()
1678 .unwrap()
1679 .join("styles/embedded/chicago-notes-18th.yaml")
1680 .to_str()
1681 .unwrap()
1682 .to_string()
1683 }
1684
1685 fn make_citation_occ(id: &str, ref_id: &str, mode: Option<CitationMode>) -> CitationOccurrence {
1686 CitationOccurrence {
1687 id: id.to_string(),
1688 items: vec![CitationOccurrenceItem {
1689 id: ref_id.to_string(),
1690 locator: None,
1691 prefix: None,
1692 suffix: None,
1693 integral_name_state: None,
1694 org_abbreviation_state: None,
1695 }],
1696 mode,
1697 note_number: None,
1698 suppress_author: None,
1699 grouped: None,
1700 prefix: None,
1701 suffix: None,
1702 sentence_start: None,
1703 }
1704 }
1705
1706 #[test]
1707 fn format_document_author_date_mixed_citation_modes_order_preserved() {
1708 let refs = RefsInput::Json(serde_json::json!({
1709 "smith2020": {
1710 "id": "smith2020",
1711 "class": "monograph",
1712 "type": "book",
1713 "title": "Sample Work",
1714 "author": [{"family": "Smith", "given": "John"}],
1715 "issued": "2020"
1716 }
1717 }));
1718
1719 let request = FormatDocumentRequest {
1720 style: StyleInput::Path(apa_style_path()),
1721 style_overrides: None,
1722 locale: None,
1723 output_format: OutputFormatKind::Plain,
1724 refs,
1725 citations: vec![
1726 make_citation_occ("cite-integral", "smith2020", Some(CitationMode::Integral)),
1727 make_citation_occ(
1728 "cite-non-integral",
1729 "smith2020",
1730 Some(CitationMode::NonIntegral),
1731 ),
1732 ],
1733 bibliography_blocks: Vec::new(),
1734 document_options: None,
1735 nocite: vec![],
1736 };
1737
1738 let result = format_document(request).expect("format_document should succeed");
1739
1740 assert_eq!(
1741 result.formatted_citations.len(),
1742 2,
1743 "both citations should be returned"
1744 );
1745 assert_eq!(
1746 result.formatted_citations[0].id, "cite-integral",
1747 "document order must be preserved"
1748 );
1749 assert_eq!(
1750 result.formatted_citations[1].id, "cite-non-integral",
1751 "document order must be preserved"
1752 );
1753
1754 let integral = &result.formatted_citations[0].text;
1755 let non_integral = &result.formatted_citations[1].text;
1756
1757 assert!(
1758 !integral.starts_with('('),
1759 "integral citation should place author name outside parentheses: {integral:?}"
1760 );
1761 assert!(
1762 integral.contains("Smith"),
1763 "integral citation should contain author name: {integral:?}"
1764 );
1765 assert!(
1766 non_integral.starts_with('('),
1767 "non-integral citation should be fully parenthetical: {non_integral:?}"
1768 );
1769 }
1770
1771 #[test]
1772 fn format_document_note_style_repeat_citations_produce_ibid() {
1773 let refs = RefsInput::Json(serde_json::json!({
1774 "smith1995": {
1775 "id": "smith1995",
1776 "class": "monograph",
1777 "type": "book",
1778 "title": "A Great Book",
1779 "author": [{"family": "Smith", "given": "John"}],
1780 "issued": "1995"
1781 }
1782 }));
1783
1784 let request = FormatDocumentRequest {
1785 style: StyleInput::Path(chicago_notes_path()),
1786 style_overrides: None,
1787 locale: None,
1788 output_format: OutputFormatKind::Plain,
1789 refs,
1790 citations: vec![
1791 make_citation_occ("cite-1", "smith1995", None),
1792 make_citation_occ("cite-2", "smith1995", None),
1793 make_citation_occ("cite-3", "smith1995", None),
1794 ],
1795 bibliography_blocks: Vec::new(),
1796 document_options: None,
1797 nocite: vec![],
1798 };
1799
1800 let result = format_document(request).expect("format_document should succeed");
1801
1802 assert_eq!(result.formatted_citations.len(), 3);
1803
1804 let first = &result.formatted_citations[0].text;
1805 let second = &result.formatted_citations[1].text;
1806 let third = &result.formatted_citations[2].text;
1807
1808 assert!(
1809 first.contains("Smith"),
1810 "first citation should render full form: {first:?}"
1811 );
1812 assert_eq!(
1813 second.as_str(),
1814 "Ibid.",
1815 "immediate repeat should render as ibid: {second:?}"
1816 );
1817 assert_eq!(
1818 third.as_str(),
1819 "Ibid.",
1820 "third repeat should also render as ibid: {third:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn format_document_annotations_appear_in_bibliography() {
1826 let refs = RefsInput::Json(serde_json::json!({
1827 "smith2020": {
1828 "id": "smith2020",
1829 "class": "monograph",
1830 "type": "book",
1831 "title": "Sample Work",
1832 "author": [{"family": "Smith", "given": "John"}],
1833 "issued": "2020"
1834 }
1835 }));
1836
1837 let mut annotations = HashMap::new();
1838 annotations.insert(
1839 "smith2020".to_string(),
1840 "Foundational work on the topic.".to_string(),
1841 );
1842
1843 let request = FormatDocumentRequest {
1844 style: StyleInput::Path(apa_style_path()),
1845 style_overrides: None,
1846 locale: None,
1847 output_format: OutputFormatKind::Plain,
1848 refs,
1849 citations: vec![make_citation_occ("cite-1", "smith2020", None)],
1850 bibliography_blocks: Vec::new(),
1851 document_options: Some(DocumentOptions {
1852 annotations: Some(annotations),
1853 ..Default::default()
1854 }),
1855 nocite: vec![],
1856 };
1857
1858 let result = format_document(request).expect("format_document should succeed");
1859
1860 assert!(
1861 result
1862 .bibliography
1863 .content
1864 .contains("Foundational work on the topic."),
1865 "annotation text should appear in bibliography output: {:?}",
1866 result.bibliography.content
1867 );
1868 }
1869}