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