1use crate::api::AnnotationStyle;
9use crate::error::ProcessorError;
10use crate::processor::Processor;
11use crate::reference::{Bibliography, 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;
20use citum_schema::locale::{GeneralTerm, TermForm};
21use citum_schema::reference::{
22 ClassExtension, CollectionType, ContributorRole as ReferenceRole, MonographComponentType,
23 MonographType, ReferenceClass, SerialComponentType,
24};
25use citum_schema::template::ContributorRole as TemplateRole;
26
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29
30use super::{
31 BibliographyEntry, CitationOccurrence, DocumentOptions, EntryMetadata, FormattedBibliography,
32 FormattedBibliographyBlock, FormattedCitation, OutputFormatKind, RefsInput, StyleInput,
33 Warning, WarningLevel,
34};
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FormatDocumentRequest {
39 pub style: StyleInput,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub style_overrides: Option<String>,
51 pub locale: Option<String>,
56 #[serde(default)]
59 pub output_format: OutputFormatKind,
60 pub refs: RefsInput,
62 pub citations: Vec<CitationOccurrence>,
64 #[serde(default)]
66 pub bibliography_blocks: Vec<super::BibliographyBlockRequest>,
67 pub document_options: Option<DocumentOptions>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FormatDocumentResult {
74 pub formatted_citations: Vec<FormattedCitation>,
76 pub bibliography: FormattedBibliography,
78 pub bibliography_blocks: Vec<FormattedBibliographyBlock>,
80 pub warnings: Vec<Warning>,
82}
83
84#[derive(Debug)]
86pub enum FormatDocumentError {
87 UnresolvedInput(String),
89 StyleParse(String),
91 StylePath(String),
93 RefsInputPath(String),
95 RefsInputParse(String),
97 Processing(ProcessorError),
99 StyleResolution(String),
101}
102
103impl std::fmt::Display for FormatDocumentError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 Self::UnresolvedInput(msg) => write!(f, "Unresolved style input: {}", msg),
107 Self::StyleParse(msg) => write!(f, "Style parse error: {}", msg),
108 Self::StylePath(msg) => write!(f, "Style path error: {}", msg),
109 Self::RefsInputPath(msg) => write!(f, "Refs input path error: {}", msg),
110 Self::RefsInputParse(msg) => write!(f, "Refs input parse error: {}", msg),
111 Self::Processing(err) => write!(f, "Processing error: {}", err),
112 Self::StyleResolution(msg) => write!(f, "Style resolution error: {}", msg),
113 }
114 }
115}
116
117impl std::error::Error for FormatDocumentError {}
118
119impl From<ProcessorError> for FormatDocumentError {
120 fn from(err: ProcessorError) -> Self {
121 Self::Processing(err)
122 }
123}
124
125pub fn apply_style_overrides(
139 style: &mut Style,
140 overlay_src: &str,
141) -> Result<(), FormatDocumentError> {
142 let overlay = Style::from_yaml_bytes(overlay_src.as_bytes()).map_err(|e| {
143 FormatDocumentError::StyleParse(format!("Failed to parse style_overrides: {e}"))
144 })?;
145 style.apply_overlay(&overlay);
146 style.apply_scoped_options();
147 Ok(())
148}
149
150pub fn format_document(
160 request: FormatDocumentRequest,
161) -> Result<FormatDocumentResult, FormatDocumentError> {
162 let style = request.style.resolve_local()?;
163 format_document_with_style(style, request)
164}
165
166pub fn format_document_with_resolver(
177 request: FormatDocumentRequest,
178 resolver: &citum_schema::StyleResolver,
179) -> Result<FormatDocumentResult, FormatDocumentError> {
180 let style = match &request.style {
181 StyleInput::Yaml(_) => request.style.resolve_local()?,
182 StyleInput::Id(value) | StyleInput::Uri(value) | StyleInput::Path(value) => resolver
183 .resolve_style(value)
184 .map_err(|e| FormatDocumentError::UnresolvedInput(e.to_string()))?,
185 };
186 let mut resolved = style
190 .try_into_resolved_with(Some(resolver))
191 .map_err(|e| FormatDocumentError::StyleResolution(e.to_string()))?;
192 resolved.extends = None;
193 format_document_with_style(resolved, request)
194}
195
196#[allow(
205 clippy::too_many_lines,
206 reason = "match arms grow one-to-one with format variants"
207)]
208pub fn format_document_with_style(
209 style: Style,
210 request: FormatDocumentRequest,
211) -> Result<FormatDocumentResult, FormatDocumentError> {
212 let mut warnings = Vec::new();
213
214 let mut style = style;
216 if let Some(src) = &request.style_overrides {
217 apply_style_overrides(&mut style, src)?;
218 }
219
220 if let Some(tag) = &request.locale
225 && !tag.is_empty()
226 && !tag.eq_ignore_ascii_case("en-us")
227 {
228 warnings.push(Warning {
229 level: WarningLevel::Warning,
230 code: "locale_fallback".to_string(),
231 citation_id: None,
232 ref_id: None,
233 message: format!(
234 "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
235 ),
236 });
237 }
238
239 let bibliography = request.refs.resolve_local()?;
240 let mut processor = Processor::new(style, bibliography);
241 warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
242 warnings.extend(unknown_enum_warnings(&processor));
243
244 if let Some(opts) = &request.document_options {
245 if let Some(new_proc) = processor
249 .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
250 {
251 processor = new_proc;
252 }
253 if let Some(show_semantics) = opts.show_semantics {
254 processor.show_semantics = show_semantics;
255 }
256 if let Some(inject_ast) = opts.inject_ast_indices {
257 processor.set_inject_ast_indices(inject_ast);
258 }
259 if let Some(abbr_map) = opts.abbreviation_map.clone() {
260 processor.abbreviation_map = Some(abbr_map);
261 }
262 }
263
264 let mut citations: Vec<Citation> = Vec::new();
269 for occ in request.citations {
270 let mut citation: Citation = occ.into();
271 citation.items.retain(|item| {
272 if processor.bibliography.contains_key(&item.id) {
273 true
274 } else {
275 warnings.push(Warning {
276 level: WarningLevel::Warning,
277 code: "missing_ref".to_string(),
278 citation_id: citation.id.clone(),
279 ref_id: Some(item.id.clone()),
280 message: format!("Reference '{}' not found in bibliography", item.id),
281 });
282 false
283 }
284 });
285 citations.push(citation);
286 }
287
288 processor.annotate_flat_integral_name_states(&mut citations);
292
293 let formatted_citations = match request.output_format {
295 OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations)?,
296 OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations)?,
297 OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations)?,
298 OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations)?,
299 OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations)?,
300 OutputFormatKind::Markdown => format_by_kind::<Markdown>(&processor, &citations)?,
301 };
302
303 let bibliography = match request.output_format {
305 OutputFormatKind::Plain => format_bibliography::<PlainText>(
306 &processor,
307 request.output_format,
308 request.document_options.as_ref(),
309 )?,
310 OutputFormatKind::Html => format_bibliography::<Html>(
311 &processor,
312 request.output_format,
313 request.document_options.as_ref(),
314 )?,
315 OutputFormatKind::Djot => format_bibliography::<Djot>(
316 &processor,
317 request.output_format,
318 request.document_options.as_ref(),
319 )?,
320 OutputFormatKind::Latex => format_bibliography::<Latex>(
321 &processor,
322 request.output_format,
323 request.document_options.as_ref(),
324 )?,
325 OutputFormatKind::Typst => format_bibliography::<Typst>(
326 &processor,
327 request.output_format,
328 request.document_options.as_ref(),
329 )?,
330 OutputFormatKind::Markdown => format_bibliography::<Markdown>(
331 &processor,
332 request.output_format,
333 request.document_options.as_ref(),
334 )?,
335 };
336
337 let bibliography_blocks = match request.output_format {
339 OutputFormatKind::Plain => format_bibliography_blocks::<PlainText>(
340 &processor,
341 &request.bibliography_blocks,
342 request.document_options.as_ref(),
343 )?,
344 OutputFormatKind::Html => format_bibliography_blocks::<Html>(
345 &processor,
346 &request.bibliography_blocks,
347 request.document_options.as_ref(),
348 )?,
349 OutputFormatKind::Djot => format_bibliography_blocks::<Djot>(
350 &processor,
351 &request.bibliography_blocks,
352 request.document_options.as_ref(),
353 )?,
354 OutputFormatKind::Latex => format_bibliography_blocks::<Latex>(
355 &processor,
356 &request.bibliography_blocks,
357 request.document_options.as_ref(),
358 )?,
359 OutputFormatKind::Typst => format_bibliography_blocks::<Typst>(
360 &processor,
361 &request.bibliography_blocks,
362 request.document_options.as_ref(),
363 )?,
364 OutputFormatKind::Markdown => format_bibliography_blocks::<Markdown>(
365 &processor,
366 &request.bibliography_blocks,
367 request.document_options.as_ref(),
368 )?,
369 };
370
371 Ok(FormatDocumentResult {
372 formatted_citations,
373 bibliography,
374 bibliography_blocks,
375 warnings,
376 })
377}
378
379pub fn unknown_reference_class_warnings(bibliography: &Bibliography) -> Vec<Warning> {
381 bibliography
382 .iter()
383 .filter_map(|(ref_id, reference)| {
384 let ReferenceClass::Unknown(class) = reference.class() else {
385 return None;
386 };
387 Some(Warning {
388 level: WarningLevel::Warning,
389 code: "unknown_reference_class".to_string(),
390 citation_id: None,
391 ref_id: Some(ref_id.clone()),
392 message: format!(
393 "Reference '{ref_id}' uses unknown class '{class}'; rendering will use only fields this engine understands."
394 ),
395 })
396 })
397 .collect()
398}
399
400pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
405 let mut warnings = Vec::new();
406
407 for (ref_id, reference) in &processor.bibliography {
409 match reference.extension() {
410 ClassExtension::Monograph(r) => {
411 if let MonographType::Unknown(s) = &r.r#type {
412 warnings.push(Warning {
413 level: WarningLevel::Warning,
414 code: "unknown_enum_variant".to_string(),
415 citation_id: None,
416 ref_id: Some(ref_id.clone()),
417 message: format!("Reference '{ref_id}' uses unknown monograph type '{s}'; rendering will use default monograph formatting."),
418 });
419 }
420 }
421 ClassExtension::Collection(r) => {
422 if let CollectionType::Unknown(s) = &r.r#type {
423 warnings.push(Warning {
424 level: WarningLevel::Warning,
425 code: "unknown_enum_variant".to_string(),
426 citation_id: None,
427 ref_id: Some(ref_id.clone()),
428 message: format!("Reference '{ref_id}' uses unknown collection type '{s}'; rendering will use default collection formatting."),
429 });
430 }
431 }
432 ClassExtension::CollectionComponent(r) => {
433 if let MonographComponentType::Unknown(s) = &r.r#type {
434 warnings.push(Warning {
435 level: WarningLevel::Warning,
436 code: "unknown_enum_variant".to_string(),
437 citation_id: None,
438 ref_id: Some(ref_id.clone()),
439 message: format!("Reference '{ref_id}' uses unknown monograph component type '{s}'; rendering will use default chapter formatting."),
440 });
441 }
442 }
443 ClassExtension::SerialComponent(r) => {
444 if let SerialComponentType::Unknown(s) = &r.r#type {
445 warnings.push(Warning {
446 level: WarningLevel::Warning,
447 code: "unknown_enum_variant".to_string(),
448 citation_id: None,
449 ref_id: Some(ref_id.clone()),
450 message: format!("Reference '{ref_id}' uses unknown serial component type '{s}'; rendering will use default article formatting."),
451 });
452 }
453 }
454 _ => {}
455 }
456
457 for contributor in reference.all_contributor_entries() {
458 if let ReferenceRole::Unknown(s) = &contributor.role {
459 warnings.push(Warning {
460 level: WarningLevel::Warning,
461 code: "unknown_enum_variant".to_string(),
462 citation_id: None,
463 ref_id: Some(ref_id.clone()),
464 message: format!("Reference '{ref_id}' uses unknown contributor role '{s}'; this role may be ignored during rendering."),
465 });
466 }
467 }
468 }
469
470 if let Some(templates) = &processor.style.templates {
472 for (name, template) in templates {
473 scan_template_for_unknowns(template, &format!("template '{name}'"), &mut warnings);
474 }
475 }
476 if let Some(citation) = &processor.style.citation
477 && let Some(template) = &citation.template
478 {
479 scan_template_for_unknowns(template, "citation layout", &mut warnings);
480 }
481 if let Some(bib) = &processor.style.bibliography
482 && let Some(template) = &bib.template
483 {
484 scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
485 }
486
487 warnings
488}
489
490fn scan_template_for_unknowns(
491 components: &[citum_schema::template::TemplateComponent],
492 location: &str,
493 warnings: &mut Vec<Warning>,
494) {
495 use citum_schema::template::TemplateComponent;
496 for component in components {
497 match component {
498 TemplateComponent::Term(t) => {
499 if let GeneralTerm::Unknown(s) = &t.term {
500 warnings.push(Warning {
501 level: WarningLevel::Warning,
502 code: "unknown_enum_variant".to_string(),
503 citation_id: None,
504 ref_id: None,
505 message: format!("Style {location} uses unknown locale term key '{s}'; this term may render as empty."),
506 });
507 }
508 if let Some(TermForm::Unknown(s)) = &t.form {
509 warnings.push(Warning {
510 level: WarningLevel::Warning,
511 code: "unknown_enum_variant".to_string(),
512 citation_id: None,
513 ref_id: None,
514 message: format!("Style {location} uses unknown term form '{s}'; falling back to long form."),
515 });
516 }
517 }
518 TemplateComponent::Contributor(c) => {
519 if let TemplateRole::Unknown(s) = &c.contributor {
520 warnings.push(Warning {
521 level: WarningLevel::Warning,
522 code: "unknown_enum_variant".to_string(),
523 citation_id: None,
524 ref_id: None,
525 message: format!("Style {location} uses unknown contributor role '{s}'; this role may be ignored."),
526 });
527 }
528 }
529 TemplateComponent::Date(d) => {
530 if let citum_schema::template::DateForm::Unknown(s) = &d.form {
531 warnings.push(Warning {
532 level: WarningLevel::Warning,
533 code: "unknown_enum_variant".to_string(),
534 citation_id: None,
535 ref_id: None,
536 message: format!("Style {location} uses unknown date form '{s}'; falling back to year only."),
537 });
538 }
539 }
540 TemplateComponent::Group(g) => {
541 scan_template_for_unknowns(&g.group, location, warnings);
542 }
543 _ => {}
544 }
545 }
546}
547
548pub(crate) fn format_by_kind<F>(
550 processor: &Processor,
551 citations: &[Citation],
552) -> Result<Vec<FormattedCitation>, FormatDocumentError>
553where
554 F: OutputFormat<Output = String>,
555{
556 let texts = processor.process_citations_with_format::<F>(citations)?;
557
558 let formatted = citations
559 .iter()
560 .zip(texts.iter())
561 .map(|(citation, text)| {
562 let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
563 FormattedCitation {
564 id: citation.id.clone().unwrap_or_default(),
565 text: text.clone(),
566 ref_ids,
567 }
568 })
569 .collect();
570
571 Ok(formatted)
572}
573
574pub(crate) fn format_bibliography<F>(
576 processor: &Processor,
577 format_kind: OutputFormatKind,
578 doc_opts: Option<&DocumentOptions>,
579) -> Result<FormattedBibliography, FormatDocumentError>
580where
581 F: OutputFormat<Output = String>,
582{
583 let (annotations, annotation_style) = if let Some(opts) = doc_opts {
585 if let Some(anns) = &opts.annotations {
586 let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
587 format: fmt.clone(),
588 });
589 (anns.clone(), style)
590 } else {
591 (HashMap::new(), None)
592 }
593 } else {
594 (HashMap::new(), None)
595 };
596
597 let content = if annotations.is_empty() {
599 processor
600 .render_bibliography_with_format_and_annotations::<F>(None, annotation_style.as_ref())
601 } else {
602 processor.render_bibliography_with_format_and_annotations::<F>(
603 Some(&annotations),
604 annotation_style.as_ref(),
605 )
606 };
607
608 let proc_entries = processor.process_references_with_format::<F>().bibliography;
610 let entries = proc_entries
611 .into_iter()
612 .map(|entry| {
613 let entry_anns = if annotations.is_empty() {
614 None
615 } else {
616 Some(&annotations)
617 };
618 let text = crate::render::bibliography::refs_to_string_with_format::<F>(
619 vec![entry.clone()],
620 entry_anns,
621 annotation_style.as_ref(),
622 );
623 let metadata = EntryMetadata {
624 author: entry.metadata.author.unwrap_or_default(),
625 year: entry.metadata.year.unwrap_or_default(),
626 title: entry.metadata.title.unwrap_or_default(),
627 };
628 BibliographyEntry {
629 id: entry.id,
630 text,
631 metadata,
632 }
633 })
634 .collect();
635
636 Ok(FormattedBibliography {
637 format: format_kind,
638 content,
639 entries,
640 })
641}
642
643pub(crate) fn format_bibliography_blocks<F>(
648 processor: &Processor,
649 requests: &[super::BibliographyBlockRequest],
650 doc_opts: Option<&DocumentOptions>,
651) -> Result<Vec<super::FormattedBibliographyBlock>, FormatDocumentError>
652where
653 F: OutputFormat<Output = String>,
654{
655 if requests.is_empty() {
656 return Ok(Vec::new());
657 }
658
659 let (annotations, annotation_style) = annotation_options(doc_opts);
660 let groups: Vec<_> = requests.iter().map(|r| r.group.clone()).collect();
661 let rendered = processor.render_document_bibliography_blocks::<F>(
662 &groups,
663 if annotations.is_empty() {
664 None
665 } else {
666 Some(&annotations)
667 },
668 annotation_style.as_ref(),
669 );
670
671 Ok(requests
672 .iter()
673 .zip(rendered)
674 .map(|(req, rg)| super::FormattedBibliographyBlock {
675 id: req.id.clone(),
676 heading: rg.heading,
677 content: rg.body,
678 entries: rg
679 .entries
680 .into_iter()
681 .map(|entry| {
682 proc_entry_to_bibliography_entry::<F>(
683 entry,
684 if annotations.is_empty() {
685 None
686 } else {
687 Some(&annotations)
688 },
689 annotation_style.as_ref(),
690 )
691 })
692 .collect(),
693 })
694 .collect())
695}
696
697fn annotation_options(
699 doc_opts: Option<&DocumentOptions>,
700) -> (HashMap<String, String>, Option<AnnotationStyle>) {
701 if let Some(opts) = doc_opts
702 && let Some(anns) = &opts.annotations
703 {
704 let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
705 format: fmt.clone(),
706 });
707 return (anns.clone(), style);
708 }
709 (HashMap::new(), None)
710}
711
712fn proc_entry_to_bibliography_entry<F>(
714 entry: crate::render::ProcEntry,
715 annotations: Option<&HashMap<String, String>>,
716 annotation_style: Option<&AnnotationStyle>,
717) -> BibliographyEntry
718where
719 F: OutputFormat<Output = String>,
720{
721 let text = crate::render::bibliography::refs_to_string_slice_with_format::<F>(
722 std::slice::from_ref(&entry),
723 annotations,
724 annotation_style,
725 );
726 let metadata = EntryMetadata {
727 author: entry.metadata.author.unwrap_or_default(),
728 year: entry.metadata.year.unwrap_or_default(),
729 title: entry.metadata.title.unwrap_or_default(),
730 };
731 BibliographyEntry {
732 id: entry.id,
733 text,
734 metadata,
735 }
736}
737
738#[cfg(test)]
739#[allow(
740 clippy::unwrap_used,
741 clippy::expect_used,
742 clippy::panic,
743 clippy::indexing_slicing,
744 reason = "test code uses assertions and panic"
745)]
746mod tests {
747 use super::*;
748 use crate::api::CitationOccurrenceItem;
749 use crate::{
750 Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
751 TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
752 WrapPunctuation,
753 };
754 use citum_schema::options::{AndOptions, ContributorConfig};
755 use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
756 use citum_schema::template::{TemplateTitle, TitleType};
757 use citum_schema::{BibliographySpec, CitationSpec, StyleInfo};
758
759 fn make_test_style() -> Style {
760 Style {
761 info: StyleInfo {
762 title: Some("Test Style".to_string()),
763 id: Some("test".into()),
764 ..Default::default()
765 },
766 options: Some(Config {
767 processing: Some(Processing::AuthorDate),
768 ..Default::default()
769 }),
770 citation: Some(CitationSpec {
771 template: Some(vec![
772 TemplateComponent::Contributor(TemplateContributor {
773 contributor: ContributorRole::Author,
774 form: ContributorForm::Short,
775 rendering: Rendering::default(),
776 ..Default::default()
777 }),
778 TemplateComponent::Date(TemplateDate {
779 date: TemplateDateVariable::Issued,
780 form: DateForm::Year,
781 rendering: Rendering::default(),
782 ..Default::default()
783 }),
784 ]),
785 wrap: Some(WrapPunctuation::Parentheses.into()),
786 ..Default::default()
787 }),
788 ..Default::default()
789 }
790 }
791
792 fn make_test_bibliography() -> RefsInput {
793 let mut refs = Bibliography::new();
794 refs.insert(
795 "smith2020".to_string(),
796 InputReference::Monograph(Box::new(Monograph {
797 id: Some("smith2020".into()),
798 r#type: MonographType::Book,
799 title: Some(Title::Single("Sample Work".to_string())),
800 issued: EdtfString("2020".to_string()),
801 ..Default::default()
802 })),
803 );
804 RefsInput::Json(serde_json::to_value(refs).unwrap())
805 }
806
807 fn make_markup_bibliography() -> RefsInput {
808 let mut refs = Bibliography::new();
809 refs.insert(
810 "art1".to_string(),
811 InputReference::Monograph(Box::new(Monograph {
812 id: Some("art1".into()),
813 r#type: MonographType::Book,
814 title: Some(Title::Single(
815 "_Homo sapiens_ and *modern* world".to_string(),
816 )),
817 issued: EdtfString("2023".to_string()),
818 ..Default::default()
819 })),
820 );
821 RefsInput::Json(serde_json::to_value(refs).unwrap())
822 }
823
824 #[test]
825 fn format_document_with_style_empty_citations() {
826 let style = make_test_style();
827 let refs = make_test_bibliography();
828 let request = FormatDocumentRequest {
829 style: StyleInput::Yaml("dummy".to_string()),
830 style_overrides: None,
831 locale: None,
832 output_format: OutputFormatKind::Plain,
833 refs,
834 citations: vec![],
835 bibliography_blocks: Vec::new(),
836 document_options: None,
837 };
838
839 let result = format_document_with_style(style, request);
840 assert!(result.is_ok());
841 let res = result.unwrap();
842 assert_eq!(res.formatted_citations.len(), 0);
843 }
844
845 #[test]
846 fn format_document_html_bibliography_entries_preserve_inline_markup() {
847 let mut style = make_test_style();
848 style.bibliography = Some(BibliographySpec {
849 template: Some(vec![TemplateComponent::Title(TemplateTitle {
850 title: TitleType::Primary,
851 ..Default::default()
852 })]),
853 ..Default::default()
854 });
855
856 let request = FormatDocumentRequest {
857 style: StyleInput::Yaml("dummy".to_string()),
858 style_overrides: None,
859 locale: None,
860 output_format: OutputFormatKind::Html,
861 refs: make_markup_bibliography(),
862 citations: vec![],
863 bibliography_blocks: Vec::new(),
864 document_options: None,
865 };
866
867 let result = format_document_with_style(style, request).expect("should render");
868
869 assert_eq!(
870 result.bibliography.entries[0].text, result.bibliography.content,
871 "single-entry bibliography should mirror the full bibliography payload"
872 );
873 assert!(
874 result.bibliography.entries[0].text.contains(
875 "<span class=\"citum-title\"><em>Homo sapiens</em> and <b>modern</b> world</span>"
876 ),
877 "per-entry HTML should preserve inline markup for Djot-bearing titles"
878 );
879 }
880
881 #[test]
882 fn format_document_missing_ref_warning() {
883 let style = make_test_style();
884 let refs = make_test_bibliography();
885
886 let citation_occ = CitationOccurrence {
887 id: "cite1".to_string(),
888 items: vec![CitationOccurrenceItem {
889 id: "unknown_ref".to_string(),
890 locator: None,
891 prefix: None,
892 suffix: None,
893 integral_name_state: None,
894 org_abbreviation_state: None,
895 }],
896 mode: None,
897 note_number: None,
898 suppress_author: None,
899 grouped: None,
900 prefix: None,
901 suffix: None,
902 sentence_start: None,
903 };
904
905 let request = FormatDocumentRequest {
906 style: StyleInput::Yaml("dummy".to_string()),
907 style_overrides: None,
908 locale: None,
909 output_format: OutputFormatKind::Plain,
910 refs,
911 citations: vec![citation_occ],
912 bibliography_blocks: Vec::new(),
913 document_options: None,
914 };
915
916 let result = format_document_with_style(style, request);
917 assert!(result.is_ok());
918 let res = result.unwrap();
919 assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
920 }
921
922 #[test]
923 fn format_document_unknown_reference_class_warning() {
924 let style = make_test_style();
925 let mut refs = Bibliography::new();
926 let unknown_ref: InputReference = serde_json::from_str(
927 r#"{
928 "class": "dance-performance",
929 "id": "pina2011",
930 "title": "Pina",
931 "issued": "2011",
932 "venue": "Berlin"
933 }"#,
934 )
935 .expect("unknown class should parse through the compatibility path");
936 refs.insert("pina2011".to_string(), unknown_ref);
937
938 let citation_occ = CitationOccurrence {
939 id: "cite1".to_string(),
940 items: vec![CitationOccurrenceItem {
941 id: "pina2011".to_string(),
942 locator: None,
943 prefix: None,
944 suffix: None,
945 integral_name_state: None,
946 org_abbreviation_state: None,
947 }],
948 mode: None,
949 note_number: None,
950 suppress_author: None,
951 grouped: None,
952 prefix: None,
953 suffix: None,
954 sentence_start: None,
955 };
956
957 let request = FormatDocumentRequest {
958 style: StyleInput::Yaml("dummy".to_string()),
959 style_overrides: None,
960 locale: None,
961 output_format: OutputFormatKind::Plain,
962 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
963 citations: vec![citation_occ],
964 bibliography_blocks: Vec::new(),
965 document_options: None,
966 };
967
968 let result = format_document_with_style(style, request).unwrap();
969 let warning = result
970 .warnings
971 .iter()
972 .find(|w| w.code == "unknown_reference_class")
973 .expect("unknown class warning should be emitted");
974 assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
975 assert!(warning.message.contains("dance-performance"));
976 }
977
978 #[test]
979 fn format_document_yaml_style_input() {
980 let style = make_test_style();
981 let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
982
983 let mut refs = Bibliography::new();
984 refs.insert(
985 "test2024".to_string(),
986 InputReference::Monograph(Box::new(Monograph {
987 id: Some("test2024".into()),
988 r#type: MonographType::Book,
989 title: Some(Title::Single("Test Work".to_string())),
990 issued: EdtfString("2024".to_string()),
991 ..Default::default()
992 })),
993 );
994
995 let citation_occ = CitationOccurrence {
996 id: "c1".to_string(),
997 items: vec![CitationOccurrenceItem {
998 id: "test2024".to_string(),
999 locator: None,
1000 prefix: None,
1001 suffix: None,
1002 integral_name_state: None,
1003 org_abbreviation_state: None,
1004 }],
1005 mode: None,
1006 note_number: None,
1007 suppress_author: None,
1008 grouped: None,
1009 prefix: None,
1010 suffix: None,
1011 sentence_start: None,
1012 };
1013
1014 let request = FormatDocumentRequest {
1015 style: StyleInput::Yaml(yaml_style),
1016 style_overrides: None,
1017 locale: None,
1018 output_format: OutputFormatKind::Plain,
1019 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1020 citations: vec![citation_occ],
1021 bibliography_blocks: Vec::new(),
1022 document_options: None,
1023 };
1024
1025 let result = format_document(request);
1026 assert!(result.is_ok());
1027 let res = result.unwrap();
1028 assert_eq!(res.formatted_citations.len(), 1);
1029 assert!(!res.formatted_citations[0].text.is_empty());
1030 }
1031
1032 #[test]
1033 fn format_document_uri_input_unresolved() {
1034 let request = FormatDocumentRequest {
1035 style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
1036 style_overrides: None,
1037 locale: None,
1038 output_format: OutputFormatKind::Plain,
1039 refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
1040 citations: vec![],
1041 bibliography_blocks: Vec::new(),
1042 document_options: None,
1043 };
1044
1045 let result = format_document(request);
1046 match result {
1047 Err(FormatDocumentError::UnresolvedInput(_)) => {
1048 }
1050 _ => panic!("Expected UnresolvedInput error"),
1051 }
1052 }
1053
1054 struct MockResolver(Style);
1056
1057 impl citum_resolver_api::StyleResolver for MockResolver {
1058 type Style = Style;
1059 type Locale = citum_schema::locale::Locale;
1060
1061 fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
1062 Ok(self.0.clone())
1063 }
1064
1065 fn resolve_locale(
1066 &self,
1067 id: &str,
1068 ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
1069 Err(citum_schema::ResolverError::LocaleNotFound(
1070 std::borrow::Cow::Owned(id.to_string()),
1071 ))
1072 }
1073 }
1074
1075 #[test]
1076 fn format_document_with_resolver_injects_style_for_id_input() {
1077 let style = make_test_style();
1078 let resolver = MockResolver(style);
1079 let refs = make_test_bibliography();
1080
1081 let citation_occ = CitationOccurrence {
1082 id: "c1".to_string(),
1083 items: vec![CitationOccurrenceItem {
1084 id: "smith2020".to_string(),
1085 locator: None,
1086 prefix: None,
1087 suffix: None,
1088 integral_name_state: None,
1089 org_abbreviation_state: None,
1090 }],
1091 mode: None,
1092 note_number: None,
1093 suppress_author: None,
1094 grouped: None,
1095 prefix: None,
1096 suffix: None,
1097 sentence_start: None,
1098 };
1099
1100 let request = FormatDocumentRequest {
1101 style: StyleInput::Id("any-id".to_string()),
1102 style_overrides: None,
1103 locale: None,
1104 output_format: OutputFormatKind::Plain,
1105 refs,
1106 citations: vec![citation_occ],
1107 bibliography_blocks: Vec::new(),
1108 document_options: None,
1109 };
1110
1111 match format_document(request.clone()) {
1113 Err(FormatDocumentError::UnresolvedInput(_)) => {}
1114 other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
1115 }
1116
1117 let result = format_document_with_resolver(request, &resolver);
1119 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
1120 let res = result.unwrap();
1121 assert_eq!(res.formatted_citations.len(), 1);
1122 assert!(
1123 !res.formatted_citations[0].text.is_empty(),
1124 "formatted citation text should not be empty"
1125 );
1126 }
1127
1128 fn make_two_author_style() -> Style {
1130 Style {
1131 info: StyleInfo {
1132 title: Some("Override Test Style".to_string()),
1133 id: Some("override-test".into()),
1134 ..Default::default()
1135 },
1136 options: Some(Config {
1137 processing: Some(Processing::AuthorDate),
1138 contributors: Some(ContributorConfig {
1141 and: Some(AndOptions::Text),
1142 ..Default::default()
1143 }),
1144 ..Default::default()
1145 }),
1146 citation: Some(CitationSpec {
1147 template: Some(vec![
1148 TemplateComponent::Contributor(TemplateContributor {
1149 contributor: ContributorRole::Author,
1150 form: ContributorForm::Short,
1151 rendering: Rendering::default(),
1152 ..Default::default()
1153 }),
1154 TemplateComponent::Date(TemplateDate {
1155 date: TemplateDateVariable::Issued,
1156 form: DateForm::Year,
1157 rendering: Rendering {
1158 prefix: Some(", ".to_string()),
1159 ..Default::default()
1160 },
1161 ..Default::default()
1162 }),
1163 ]),
1164 wrap: Some(WrapPunctuation::Parentheses.into()),
1165 ..Default::default()
1166 }),
1167 ..Default::default()
1168 }
1169 }
1170
1171 fn make_two_author_refs() -> RefsInput {
1177 RefsInput::Yaml(
1178 r#"duo2024:
1179 class: monograph
1180 id: duo2024
1181 type: book
1182 title: Duo Work
1183 issued: "2024"
1184 author:
1185 - family: Smith
1186 given: Alice
1187 - family: Jones
1188 given: Bob
1189"#
1190 .to_string(),
1191 )
1192 }
1193
1194 fn cite(ref_id: &str) -> CitationOccurrence {
1196 CitationOccurrence {
1197 id: "c1".to_string(),
1198 items: vec![CitationOccurrenceItem {
1199 id: ref_id.to_string(),
1200 locator: None,
1201 prefix: None,
1202 suffix: None,
1203 integral_name_state: None,
1204 org_abbreviation_state: None,
1205 }],
1206 mode: None,
1207 note_number: None,
1208 suppress_author: None,
1209 grouped: None,
1210 prefix: None,
1211 suffix: None,
1212 sentence_start: None,
1213 }
1214 }
1215
1216 #[test]
1217 fn style_overrides_and_symbol_changes_rendered_output() {
1218 let base_style = make_two_author_style();
1219 let refs = make_two_author_refs();
1220
1221 let request_base = FormatDocumentRequest {
1223 style: StyleInput::Yaml("dummy".to_string()),
1224 style_overrides: None,
1225 locale: None,
1226 output_format: OutputFormatKind::Plain,
1227 refs: refs.clone(),
1228 citations: vec![cite("duo2024")],
1229 bibliography_blocks: Vec::new(),
1230 document_options: None,
1231 };
1232 let result_base = format_document_with_style(base_style.clone(), request_base).unwrap();
1233 let text_base = &result_base.formatted_citations[0].text;
1234 assert!(
1235 text_base.contains("and"),
1236 "base style should use text 'and' connector, got: {text_base:?}"
1237 );
1238
1239 let request_override = FormatDocumentRequest {
1241 style: StyleInput::Yaml("dummy".to_string()),
1242 style_overrides: Some("options:\n contributors:\n and: symbol\n".to_string()),
1243 locale: None,
1244 output_format: OutputFormatKind::Plain,
1245 refs,
1246 citations: vec![cite("duo2024")],
1247 bibliography_blocks: Vec::new(),
1248 document_options: None,
1249 };
1250 let result_override =
1251 format_document_with_style(base_style.clone(), request_override).unwrap();
1252 let text_override = &result_override.formatted_citations[0].text;
1253 assert!(
1254 text_override.contains('&'),
1255 "overridden style should use '&' connector, got: {text_override:?}"
1256 );
1257
1258 let base_and = base_style
1260 .options
1261 .as_ref()
1262 .and_then(|o| o.contributors.as_ref())
1263 .and_then(|c| c.and.as_ref());
1264 assert!(
1265 matches!(base_and, Some(&AndOptions::Text)),
1266 "base style must not be mutated; expected And::Text, got: {base_and:?}"
1267 );
1268 }
1269
1270 #[test]
1271 fn style_overrides_invalid_yaml_returns_parse_error() {
1272 let style = make_test_style();
1273 let refs = make_test_bibliography();
1274
1275 let request = FormatDocumentRequest {
1276 style: StyleInput::Yaml("dummy".to_string()),
1277 style_overrides: Some("{ unclosed yaml: [".to_string()),
1278 locale: None,
1279 output_format: OutputFormatKind::Plain,
1280 refs,
1281 citations: vec![],
1282 bibliography_blocks: Vec::new(),
1283 document_options: None,
1284 };
1285
1286 match format_document_with_style(style, request) {
1287 Err(FormatDocumentError::StyleParse(msg)) => {
1288 assert!(
1289 msg.contains("style_overrides"),
1290 "error message should mention style_overrides, got: {msg}"
1291 );
1292 }
1293 other => panic!("expected StyleParse error, got: {other:?}"),
1294 }
1295 }
1296
1297 #[test]
1298 fn apply_style_overrides_merges_option_field() {
1299 let mut style = make_test_style();
1300 apply_style_overrides(&mut style, "options:\n contributors:\n and: symbol\n")
1301 .expect("apply_style_overrides should succeed");
1302
1303 let and_option = style
1304 .options
1305 .as_ref()
1306 .and_then(|o| o.contributors.as_ref())
1307 .and_then(|c| c.and.as_ref());
1308 assert!(
1309 matches!(and_option, Some(&AndOptions::Symbol)),
1310 "expected And::Symbol after override, got: {and_option:?}"
1311 );
1312 }
1313
1314 fn make_integral_name_style() -> Style {
1320 use citum_schema::options::{
1321 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1322 };
1323 Style {
1324 info: StyleInfo {
1325 title: Some("Integral Name Memory Test".to_string()),
1326 id: Some("integral-name-memory-test".into()),
1327 ..Default::default()
1328 },
1329 options: Some(Config {
1330 processing: Some(Processing::AuthorDate),
1331 integral_name_memory: Some(IntegralNameMemoryConfig {
1332 scope: Some(IntegralNameScope::Document),
1333 contexts: Some(IntegralNameContexts::BodyAndNotes),
1334 subsequent_form: Some(SubsequentNameForm::Short),
1335 ..Default::default()
1336 }),
1337 ..Default::default()
1338 }),
1339 citation: Some(CitationSpec {
1340 integral: Some(Box::new(CitationSpec {
1341 template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1342 contributor: ContributorRole::Author,
1343 form: ContributorForm::Long,
1344 rendering: Rendering::default(),
1345 ..Default::default()
1346 })]),
1347 ..Default::default()
1348 })),
1349 template: Some(vec![
1350 TemplateComponent::Contributor(TemplateContributor {
1351 contributor: ContributorRole::Author,
1352 form: ContributorForm::Short,
1353 rendering: Rendering::default(),
1354 ..Default::default()
1355 }),
1356 TemplateComponent::Date(TemplateDate {
1357 date: TemplateDateVariable::Issued,
1358 form: DateForm::Year,
1359 rendering: Rendering::default(),
1360 ..Default::default()
1361 }),
1362 ]),
1363 wrap: Some(WrapPunctuation::Parentheses.into()),
1364 ..Default::default()
1365 }),
1366 ..Default::default()
1367 }
1368 }
1369
1370 fn make_smith_refs() -> RefsInput {
1371 RefsInput::Yaml(
1372 r#"smith2020:
1373 class: monograph
1374 id: smith2020
1375 type: book
1376 title: Smith Book
1377 issued: "2020"
1378 author:
1379 - family: Smith
1380 given: John
1381"#
1382 .to_string(),
1383 )
1384 }
1385
1386 fn make_integral_occ(id: &str, ref_id: &str) -> CitationOccurrence {
1387 CitationOccurrence {
1388 id: id.to_string(),
1389 items: vec![CitationOccurrenceItem {
1390 id: ref_id.to_string(),
1391 locator: None,
1392 prefix: None,
1393 suffix: None,
1394 integral_name_state: None,
1395 org_abbreviation_state: None,
1396 }],
1397 mode: Some(citum_schema::data::citation::CitationMode::Integral),
1398 note_number: None,
1399 suppress_author: None,
1400 grouped: None,
1401 prefix: None,
1402 suffix: None,
1403 sentence_start: None,
1404 }
1405 }
1406
1407 #[test]
1408 fn document_options_integral_name_memory_first_full_then_short() {
1409 use crate::processor::document::DocumentIntegralNameOverride;
1410
1411 let style = make_integral_name_style();
1412 let refs = make_smith_refs();
1413
1414 let request = FormatDocumentRequest {
1415 style: StyleInput::Yaml("dummy".to_string()),
1416 style_overrides: None,
1417 locale: None,
1418 output_format: OutputFormatKind::Plain,
1419 refs,
1420 citations: vec![
1421 make_integral_occ("c1", "smith2020"),
1422 make_integral_occ("c2", "smith2020"),
1423 ],
1424 bibliography_blocks: Vec::new(),
1425 document_options: Some(DocumentOptions {
1426 integral_name_memory: Some(DocumentIntegralNameOverride {
1427 enabled: Some(true),
1428 ..Default::default()
1429 }),
1430 ..Default::default()
1431 }),
1432 };
1433
1434 let result = format_document_with_style(style, request).expect("should render");
1435
1436 assert!(
1437 !result
1438 .warnings
1439 .iter()
1440 .any(|w| w.code == "integral_name_memory_not_applied"),
1441 "stale warning must not appear: {:?}",
1442 result.warnings
1443 );
1444 assert_eq!(
1445 result.formatted_citations[0].text, "John Smith",
1446 "first integral cite should render full name form"
1447 );
1448 assert_eq!(
1449 result.formatted_citations[1].text, "Smith",
1450 "second integral cite of same author should render short form"
1451 );
1452 }
1453
1454 #[test]
1455 fn document_options_integral_name_memory_disabled_keeps_full_form() {
1456 use crate::processor::document::DocumentIntegralNameOverride;
1457
1458 let style = make_integral_name_style();
1459 let refs = make_smith_refs();
1460
1461 let request = FormatDocumentRequest {
1462 style: StyleInput::Yaml("dummy".to_string()),
1463 style_overrides: None,
1464 locale: None,
1465 output_format: OutputFormatKind::Plain,
1466 refs,
1467 citations: vec![
1468 make_integral_occ("c1", "smith2020"),
1469 make_integral_occ("c2", "smith2020"),
1470 ],
1471 bibliography_blocks: Vec::new(),
1472 document_options: Some(DocumentOptions {
1473 integral_name_memory: Some(DocumentIntegralNameOverride {
1474 enabled: Some(false),
1475 ..Default::default()
1476 }),
1477 ..Default::default()
1478 }),
1479 };
1480
1481 let result = format_document_with_style(style, request).expect("should render");
1482
1483 assert_eq!(
1486 result.formatted_citations[0].text, "John Smith",
1487 "first integral cite: {}",
1488 result.formatted_citations[0].text
1489 );
1490 assert_eq!(
1491 result.formatted_citations[1].text, "John Smith",
1492 "second integral cite should also be full when memory is disabled"
1493 );
1494 }
1495
1496 #[test]
1497 fn style_native_integral_name_memory_applied_without_document_override() {
1498 let style = make_integral_name_style();
1501 let refs = make_smith_refs();
1502
1503 let request = FormatDocumentRequest {
1504 style: StyleInput::Yaml("dummy".to_string()),
1505 style_overrides: None,
1506 locale: None,
1507 output_format: OutputFormatKind::Plain,
1508 refs,
1509 citations: vec![
1510 make_integral_occ("c1", "smith2020"),
1511 make_integral_occ("c2", "smith2020"),
1512 ],
1513 bibliography_blocks: Vec::new(),
1514 document_options: None,
1515 };
1516
1517 let result = format_document_with_style(style, request).expect("should render");
1518
1519 assert_eq!(
1520 result.formatted_citations[0].text, "John Smith",
1521 "first integral cite should render full name form"
1522 );
1523 assert_eq!(
1524 result.formatted_citations[1].text, "Smith",
1525 "second integral cite should render short form from style-native config"
1526 );
1527 }
1528
1529 #[test]
1530 fn format_document_bibliography_blocks_ordered_with_dedup() {
1531 use citum_schema::grouping::CitedStatus;
1532 use citum_schema::grouping::{BibliographyGroup, GroupSelector};
1533
1534 let mut style = make_test_style();
1535 style.bibliography = Some(BibliographySpec {
1536 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1537 title: TitleType::Primary,
1538 ..Default::default()
1539 })]),
1540 ..Default::default()
1541 });
1542 let mut refs = Bibliography::new();
1543 refs.insert(
1544 "smith2020".to_string(),
1545 InputReference::Monograph(Box::new(Monograph {
1546 id: Some("smith2020".into()),
1547 r#type: MonographType::Book,
1548 title: Some(Title::Single("Sample Work".to_string())),
1549 issued: EdtfString("2020".to_string()),
1550 ..Default::default()
1551 })),
1552 );
1553 refs.insert(
1554 "jones2019".to_string(),
1555 InputReference::Monograph(Box::new(Monograph {
1556 id: Some("jones2019".into()),
1557 r#type: MonographType::Book,
1558 title: Some(Title::Single("Another Work".to_string())),
1559 issued: EdtfString("2019".to_string()),
1560 ..Default::default()
1561 })),
1562 );
1563
1564 let make_block = |id: &str| crate::BibliographyBlockRequest {
1565 id: id.to_string(),
1566 group: BibliographyGroup {
1567 id: id.to_string(),
1568 selector: GroupSelector {
1569 cited: Some(CitedStatus::Any),
1570 ..Default::default()
1571 },
1572 ..Default::default()
1573 },
1574 };
1575
1576 let request = FormatDocumentRequest {
1577 style: StyleInput::Yaml("dummy".to_string()),
1578 style_overrides: None,
1579 locale: None,
1580 output_format: OutputFormatKind::Plain,
1581 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1582 citations: vec![],
1583 bibliography_blocks: vec![make_block("block-a"), make_block("block-b")],
1584 document_options: None,
1585 };
1586
1587 let result = format_document_with_style(style, request).expect("should render");
1588
1589 assert_eq!(result.bibliography_blocks.len(), 2, "both blocks returned");
1590 assert_eq!(result.bibliography_blocks[0].id, "block-a");
1591 assert_eq!(result.bibliography_blocks[1].id, "block-b");
1592
1593 let block_a_count = result.bibliography_blocks[0].entries.len();
1594 let block_b_count = result.bibliography_blocks[1].entries.len();
1595
1596 assert_eq!(block_a_count, 2, "block-a captures both refs");
1597 assert_eq!(
1598 block_b_count, 0,
1599 "block-b is empty: dedup set prevents re-assignment from block-a"
1600 );
1601 }
1602}