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