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