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 FormattedCitation, OutputFormatKind, RefsInput, StyleInput, Warning, WarningLevel,
33};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct FormatDocumentRequest {
38 pub style: StyleInput,
40 pub locale: Option<String>,
45 #[serde(default)]
48 pub output_format: OutputFormatKind,
49 pub refs: RefsInput,
51 pub citations: Vec<CitationOccurrence>,
53 pub document_options: Option<DocumentOptions>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FormatDocumentResult {
60 pub formatted_citations: Vec<FormattedCitation>,
62 pub bibliography: FormattedBibliography,
64 pub warnings: Vec<Warning>,
66}
67
68#[derive(Debug)]
70pub enum FormatDocumentError {
71 UnresolvedInput(String),
73 StyleParse(String),
75 StylePath(String),
77 RefsInputPath(String),
79 RefsInputParse(String),
81 Processing(ProcessorError),
83 StyleResolution(String),
85}
86
87impl std::fmt::Display for FormatDocumentError {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 match self {
90 Self::UnresolvedInput(msg) => write!(f, "Unresolved style input: {}", msg),
91 Self::StyleParse(msg) => write!(f, "Style parse error: {}", msg),
92 Self::StylePath(msg) => write!(f, "Style path error: {}", msg),
93 Self::RefsInputPath(msg) => write!(f, "Refs input path error: {}", msg),
94 Self::RefsInputParse(msg) => write!(f, "Refs input parse error: {}", msg),
95 Self::Processing(err) => write!(f, "Processing error: {}", err),
96 Self::StyleResolution(msg) => write!(f, "Style resolution error: {}", msg),
97 }
98 }
99}
100
101impl std::error::Error for FormatDocumentError {}
102
103impl From<ProcessorError> for FormatDocumentError {
104 fn from(err: ProcessorError) -> Self {
105 Self::Processing(err)
106 }
107}
108
109pub fn format_document(
119 request: FormatDocumentRequest,
120) -> Result<FormatDocumentResult, FormatDocumentError> {
121 let style = request.style.resolve_local()?;
122 format_document_with_style(style, request)
123}
124
125pub fn format_document_with_resolver(
136 request: FormatDocumentRequest,
137 resolver: &citum_schema::StyleResolver,
138) -> Result<FormatDocumentResult, FormatDocumentError> {
139 let style = match &request.style {
140 StyleInput::Yaml(_) => request.style.resolve_local()?,
141 StyleInput::Id(value) | StyleInput::Uri(value) | StyleInput::Path(value) => resolver
142 .resolve_style(value)
143 .map_err(|e| FormatDocumentError::UnresolvedInput(e.to_string()))?,
144 };
145 let mut resolved = style
149 .try_into_resolved_with(Some(resolver))
150 .map_err(|e| FormatDocumentError::StyleResolution(e.to_string()))?;
151 resolved.extends = None;
152 format_document_with_style(resolved, request)
153}
154
155#[allow(
164 clippy::too_many_lines,
165 reason = "match arms grow one-to-one with format variants"
166)]
167pub fn format_document_with_style(
168 style: Style,
169 request: FormatDocumentRequest,
170) -> Result<FormatDocumentResult, FormatDocumentError> {
171 let mut warnings = Vec::new();
172
173 if let Some(tag) = &request.locale
178 && !tag.is_empty()
179 && !tag.eq_ignore_ascii_case("en-us")
180 {
181 warnings.push(Warning {
182 level: WarningLevel::Warning,
183 code: "locale_fallback".to_string(),
184 citation_id: None,
185 ref_id: None,
186 message: format!(
187 "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
188 ),
189 });
190 }
191
192 let bibliography = request.refs.resolve_local()?;
193 let mut processor = Processor::new(style, bibliography);
194 warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
195 warnings.extend(unknown_enum_warnings(&processor));
196
197 if let Some(opts) = &request.document_options {
198 if let Some(show_semantics) = opts.show_semantics {
199 processor.show_semantics = show_semantics;
200 }
201 if let Some(inject_ast) = opts.inject_ast_indices {
202 processor.set_inject_ast_indices(inject_ast);
203 }
204 if let Some(abbr_map) = opts.abbreviation_map.clone() {
205 processor.abbreviation_map = Some(abbr_map);
206 }
207 if opts.integral_name_memory.is_some() {
208 warnings.push(Warning {
209 level: WarningLevel::Warning,
210 code: "integral_name_memory_not_applied".to_string(),
211 citation_id: None,
212 ref_id: None,
213 message: "document_options.integral_name_memory is accepted but not yet wired through the processor; tracked in csl26-ktq6.".to_string(),
214 });
215 }
216 }
217
218 let mut citations: Vec<Citation> = Vec::new();
223 for occ in request.citations {
224 let mut citation: Citation = occ.into();
225 citation.items.retain(|item| {
226 if processor.bibliography.contains_key(&item.id) {
227 true
228 } else {
229 warnings.push(Warning {
230 level: WarningLevel::Warning,
231 code: "missing_ref".to_string(),
232 citation_id: citation.id.clone(),
233 ref_id: Some(item.id.clone()),
234 message: format!("Reference '{}' not found in bibliography", item.id),
235 });
236 false
237 }
238 });
239 citations.push(citation);
240 }
241
242 let formatted_citations = match request.output_format {
244 OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations)?,
245 OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations)?,
246 OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations)?,
247 OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations)?,
248 OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations)?,
249 OutputFormatKind::Markdown => format_by_kind::<Markdown>(&processor, &citations)?,
250 };
251
252 let bibliography = match request.output_format {
254 OutputFormatKind::Plain => format_bibliography::<PlainText>(
255 &processor,
256 request.output_format,
257 request.document_options.as_ref(),
258 )?,
259 OutputFormatKind::Html => format_bibliography::<Html>(
260 &processor,
261 request.output_format,
262 request.document_options.as_ref(),
263 )?,
264 OutputFormatKind::Djot => format_bibliography::<Djot>(
265 &processor,
266 request.output_format,
267 request.document_options.as_ref(),
268 )?,
269 OutputFormatKind::Latex => format_bibliography::<Latex>(
270 &processor,
271 request.output_format,
272 request.document_options.as_ref(),
273 )?,
274 OutputFormatKind::Typst => format_bibliography::<Typst>(
275 &processor,
276 request.output_format,
277 request.document_options.as_ref(),
278 )?,
279 OutputFormatKind::Markdown => format_bibliography::<Markdown>(
280 &processor,
281 request.output_format,
282 request.document_options.as_ref(),
283 )?,
284 };
285
286 Ok(FormatDocumentResult {
287 formatted_citations,
288 bibliography,
289 warnings,
290 })
291}
292
293pub fn unknown_reference_class_warnings(bibliography: &Bibliography) -> Vec<Warning> {
295 bibliography
296 .iter()
297 .filter_map(|(ref_id, reference)| {
298 let ReferenceClass::Unknown(class) = reference.class() else {
299 return None;
300 };
301 Some(Warning {
302 level: WarningLevel::Warning,
303 code: "unknown_reference_class".to_string(),
304 citation_id: None,
305 ref_id: Some(ref_id.clone()),
306 message: format!(
307 "Reference '{ref_id}' uses unknown class '{class}'; rendering will use only fields this engine understands."
308 ),
309 })
310 })
311 .collect()
312}
313
314pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
319 let mut warnings = Vec::new();
320
321 for (ref_id, reference) in &processor.bibliography {
323 match reference.extension() {
324 ClassExtension::Monograph(r) => {
325 if let MonographType::Unknown(s) = &r.r#type {
326 warnings.push(Warning {
327 level: WarningLevel::Warning,
328 code: "unknown_enum_variant".to_string(),
329 citation_id: None,
330 ref_id: Some(ref_id.clone()),
331 message: format!("Reference '{ref_id}' uses unknown monograph type '{s}'; rendering will use default monograph formatting."),
332 });
333 }
334 }
335 ClassExtension::Collection(r) => {
336 if let CollectionType::Unknown(s) = &r.r#type {
337 warnings.push(Warning {
338 level: WarningLevel::Warning,
339 code: "unknown_enum_variant".to_string(),
340 citation_id: None,
341 ref_id: Some(ref_id.clone()),
342 message: format!("Reference '{ref_id}' uses unknown collection type '{s}'; rendering will use default collection formatting."),
343 });
344 }
345 }
346 ClassExtension::CollectionComponent(r) => {
347 if let MonographComponentType::Unknown(s) = &r.r#type {
348 warnings.push(Warning {
349 level: WarningLevel::Warning,
350 code: "unknown_enum_variant".to_string(),
351 citation_id: None,
352 ref_id: Some(ref_id.clone()),
353 message: format!("Reference '{ref_id}' uses unknown monograph component type '{s}'; rendering will use default chapter formatting."),
354 });
355 }
356 }
357 ClassExtension::SerialComponent(r) => {
358 if let SerialComponentType::Unknown(s) = &r.r#type {
359 warnings.push(Warning {
360 level: WarningLevel::Warning,
361 code: "unknown_enum_variant".to_string(),
362 citation_id: None,
363 ref_id: Some(ref_id.clone()),
364 message: format!("Reference '{ref_id}' uses unknown serial component type '{s}'; rendering will use default article formatting."),
365 });
366 }
367 }
368 _ => {}
369 }
370
371 for contributor in reference.all_contributor_entries() {
372 if let ReferenceRole::Unknown(s) = &contributor.role {
373 warnings.push(Warning {
374 level: WarningLevel::Warning,
375 code: "unknown_enum_variant".to_string(),
376 citation_id: None,
377 ref_id: Some(ref_id.clone()),
378 message: format!("Reference '{ref_id}' uses unknown contributor role '{s}'; this role may be ignored during rendering."),
379 });
380 }
381 }
382 }
383
384 if let Some(templates) = &processor.style.templates {
386 for (name, template) in templates {
387 scan_template_for_unknowns(template, &format!("template '{name}'"), &mut warnings);
388 }
389 }
390 if let Some(citation) = &processor.style.citation
391 && let Some(template) = &citation.template
392 {
393 scan_template_for_unknowns(template, "citation layout", &mut warnings);
394 }
395 if let Some(bib) = &processor.style.bibliography
396 && let Some(template) = &bib.template
397 {
398 scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
399 }
400
401 warnings
402}
403
404fn scan_template_for_unknowns(
405 components: &[citum_schema::template::TemplateComponent],
406 location: &str,
407 warnings: &mut Vec<Warning>,
408) {
409 use citum_schema::template::TemplateComponent;
410 for component in components {
411 match component {
412 TemplateComponent::Term(t) => {
413 if let GeneralTerm::Unknown(s) = &t.term {
414 warnings.push(Warning {
415 level: WarningLevel::Warning,
416 code: "unknown_enum_variant".to_string(),
417 citation_id: None,
418 ref_id: None,
419 message: format!("Style {location} uses unknown locale term key '{s}'; this term may render as empty."),
420 });
421 }
422 if let Some(TermForm::Unknown(s)) = &t.form {
423 warnings.push(Warning {
424 level: WarningLevel::Warning,
425 code: "unknown_enum_variant".to_string(),
426 citation_id: None,
427 ref_id: None,
428 message: format!("Style {location} uses unknown term form '{s}'; falling back to long form."),
429 });
430 }
431 }
432 TemplateComponent::Contributor(c) => {
433 if let TemplateRole::Unknown(s) = &c.contributor {
434 warnings.push(Warning {
435 level: WarningLevel::Warning,
436 code: "unknown_enum_variant".to_string(),
437 citation_id: None,
438 ref_id: None,
439 message: format!("Style {location} uses unknown contributor role '{s}'; this role may be ignored."),
440 });
441 }
442 }
443 TemplateComponent::Date(d) => {
444 if let citum_schema::template::DateForm::Unknown(s) = &d.form {
445 warnings.push(Warning {
446 level: WarningLevel::Warning,
447 code: "unknown_enum_variant".to_string(),
448 citation_id: None,
449 ref_id: None,
450 message: format!("Style {location} uses unknown date form '{s}'; falling back to year only."),
451 });
452 }
453 }
454 TemplateComponent::Group(g) => {
455 scan_template_for_unknowns(&g.group, location, warnings);
456 }
457 _ => {}
458 }
459 }
460}
461
462pub(crate) fn format_by_kind<F>(
464 processor: &Processor,
465 citations: &[Citation],
466) -> Result<Vec<FormattedCitation>, FormatDocumentError>
467where
468 F: OutputFormat<Output = String>,
469{
470 let texts = processor.process_citations_with_format::<F>(citations)?;
471
472 let formatted = citations
473 .iter()
474 .zip(texts.iter())
475 .map(|(citation, text)| {
476 let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
477 FormattedCitation {
478 id: citation.id.clone().unwrap_or_default(),
479 text: text.clone(),
480 ref_ids,
481 }
482 })
483 .collect();
484
485 Ok(formatted)
486}
487
488pub(crate) fn format_bibliography<F>(
490 processor: &Processor,
491 format_kind: OutputFormatKind,
492 doc_opts: Option<&DocumentOptions>,
493) -> Result<FormattedBibliography, FormatDocumentError>
494where
495 F: OutputFormat<Output = String>,
496{
497 let (annotations, annotation_style) = if let Some(opts) = doc_opts {
499 if let Some(anns) = &opts.annotations {
500 let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
501 format: fmt.clone(),
502 });
503 (anns.clone(), style)
504 } else {
505 (HashMap::new(), None)
506 }
507 } else {
508 (HashMap::new(), None)
509 };
510
511 let content = if annotations.is_empty() {
513 processor
514 .render_bibliography_with_format_and_annotations::<F>(None, annotation_style.as_ref())
515 } else {
516 processor.render_bibliography_with_format_and_annotations::<F>(
517 Some(&annotations),
518 annotation_style.as_ref(),
519 )
520 };
521
522 let proc_entries = processor.process_references().bibliography;
524 let entries = proc_entries
525 .into_iter()
526 .map(|entry| {
527 let entry_anns = if annotations.is_empty() {
528 None
529 } else {
530 Some(&annotations)
531 };
532 let text = crate::render::bibliography::refs_to_string_with_format::<F>(
533 vec![entry.clone()],
534 entry_anns,
535 annotation_style.as_ref(),
536 );
537 let metadata = EntryMetadata {
538 author: entry.metadata.author.unwrap_or_default(),
539 year: entry.metadata.year.unwrap_or_default(),
540 title: entry.metadata.title.unwrap_or_default(),
541 };
542 BibliographyEntry {
543 id: entry.id,
544 text,
545 metadata,
546 }
547 })
548 .collect();
549
550 Ok(FormattedBibliography {
551 format: format_kind,
552 content,
553 entries,
554 })
555}
556
557#[cfg(test)]
558#[allow(
559 clippy::unwrap_used,
560 clippy::expect_used,
561 clippy::panic,
562 clippy::indexing_slicing,
563 reason = "test code uses assertions and panic"
564)]
565mod tests {
566 use super::*;
567 use crate::api::CitationOccurrenceItem;
568 use crate::{
569 Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
570 TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
571 WrapPunctuation,
572 };
573 use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
574 use citum_schema::{CitationSpec, StyleInfo};
575
576 fn make_test_style() -> Style {
577 Style {
578 info: StyleInfo {
579 title: Some("Test Style".to_string()),
580 id: Some("test".into()),
581 ..Default::default()
582 },
583 options: Some(Config {
584 processing: Some(Processing::AuthorDate),
585 ..Default::default()
586 }),
587 citation: Some(CitationSpec {
588 template: Some(vec![
589 TemplateComponent::Contributor(TemplateContributor {
590 contributor: ContributorRole::Author,
591 form: ContributorForm::Short,
592 rendering: Rendering::default(),
593 ..Default::default()
594 }),
595 TemplateComponent::Date(TemplateDate {
596 date: TemplateDateVariable::Issued,
597 form: DateForm::Year,
598 rendering: Rendering::default(),
599 ..Default::default()
600 }),
601 ]),
602 wrap: Some(WrapPunctuation::Parentheses.into()),
603 ..Default::default()
604 }),
605 ..Default::default()
606 }
607 }
608
609 fn make_test_bibliography() -> RefsInput {
610 let mut refs = Bibliography::new();
611 refs.insert(
612 "smith2020".to_string(),
613 InputReference::Monograph(Box::new(Monograph {
614 id: Some("smith2020".into()),
615 r#type: MonographType::Book,
616 title: Some(Title::Single("Sample Work".to_string())),
617 issued: EdtfString("2020".to_string()),
618 ..Default::default()
619 })),
620 );
621 RefsInput::Json(serde_json::to_value(refs).unwrap())
622 }
623
624 #[test]
625 fn format_document_with_style_empty_citations() {
626 let style = make_test_style();
627 let refs = make_test_bibliography();
628 let request = FormatDocumentRequest {
629 style: StyleInput::Yaml("dummy".to_string()),
630 locale: None,
631 output_format: OutputFormatKind::Plain,
632 refs,
633 citations: vec![],
634 document_options: None,
635 };
636
637 let result = format_document_with_style(style, request);
638 assert!(result.is_ok());
639 let res = result.unwrap();
640 assert_eq!(res.formatted_citations.len(), 0);
641 }
642
643 #[test]
644 fn format_document_missing_ref_warning() {
645 let style = make_test_style();
646 let refs = make_test_bibliography();
647
648 let citation_occ = CitationOccurrence {
649 id: "cite1".to_string(),
650 items: vec![CitationOccurrenceItem {
651 id: "unknown_ref".to_string(),
652 locator: None,
653 prefix: None,
654 suffix: None,
655 integral_name_state: None,
656 org_abbreviation_state: None,
657 }],
658 mode: None,
659 note_number: None,
660 suppress_author: None,
661 grouped: None,
662 prefix: None,
663 suffix: None,
664 sentence_start: None,
665 };
666
667 let request = FormatDocumentRequest {
668 style: StyleInput::Yaml("dummy".to_string()),
669 locale: None,
670 output_format: OutputFormatKind::Plain,
671 refs,
672 citations: vec![citation_occ],
673 document_options: None,
674 };
675
676 let result = format_document_with_style(style, request);
677 assert!(result.is_ok());
678 let res = result.unwrap();
679 assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
680 }
681
682 #[test]
683 fn format_document_unknown_reference_class_warning() {
684 let style = make_test_style();
685 let mut refs = Bibliography::new();
686 let unknown_ref: InputReference = serde_json::from_str(
687 r#"{
688 "class": "dance-performance",
689 "id": "pina2011",
690 "title": "Pina",
691 "issued": "2011",
692 "venue": "Berlin"
693 }"#,
694 )
695 .expect("unknown class should parse through the compatibility path");
696 refs.insert("pina2011".to_string(), unknown_ref);
697
698 let citation_occ = CitationOccurrence {
699 id: "cite1".to_string(),
700 items: vec![CitationOccurrenceItem {
701 id: "pina2011".to_string(),
702 locator: None,
703 prefix: None,
704 suffix: None,
705 integral_name_state: None,
706 org_abbreviation_state: None,
707 }],
708 mode: None,
709 note_number: None,
710 suppress_author: None,
711 grouped: None,
712 prefix: None,
713 suffix: None,
714 sentence_start: None,
715 };
716
717 let request = FormatDocumentRequest {
718 style: StyleInput::Yaml("dummy".to_string()),
719 locale: None,
720 output_format: OutputFormatKind::Plain,
721 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
722 citations: vec![citation_occ],
723 document_options: None,
724 };
725
726 let result = format_document_with_style(style, request).unwrap();
727 let warning = result
728 .warnings
729 .iter()
730 .find(|w| w.code == "unknown_reference_class")
731 .expect("unknown class warning should be emitted");
732 assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
733 assert!(warning.message.contains("dance-performance"));
734 }
735
736 #[test]
737 fn format_document_yaml_style_input() {
738 let style = make_test_style();
739 let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
740
741 let mut refs = Bibliography::new();
742 refs.insert(
743 "test2024".to_string(),
744 InputReference::Monograph(Box::new(Monograph {
745 id: Some("test2024".into()),
746 r#type: MonographType::Book,
747 title: Some(Title::Single("Test Work".to_string())),
748 issued: EdtfString("2024".to_string()),
749 ..Default::default()
750 })),
751 );
752
753 let citation_occ = CitationOccurrence {
754 id: "c1".to_string(),
755 items: vec![CitationOccurrenceItem {
756 id: "test2024".to_string(),
757 locator: None,
758 prefix: None,
759 suffix: None,
760 integral_name_state: None,
761 org_abbreviation_state: None,
762 }],
763 mode: None,
764 note_number: None,
765 suppress_author: None,
766 grouped: None,
767 prefix: None,
768 suffix: None,
769 sentence_start: None,
770 };
771
772 let request = FormatDocumentRequest {
773 style: StyleInput::Yaml(yaml_style),
774 locale: None,
775 output_format: OutputFormatKind::Plain,
776 refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
777 citations: vec![citation_occ],
778 document_options: None,
779 };
780
781 let result = format_document(request);
782 assert!(result.is_ok());
783 let res = result.unwrap();
784 assert_eq!(res.formatted_citations.len(), 1);
785 assert!(!res.formatted_citations[0].text.is_empty());
786 }
787
788 #[test]
789 fn format_document_uri_input_unresolved() {
790 let request = FormatDocumentRequest {
791 style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
792 locale: None,
793 output_format: OutputFormatKind::Plain,
794 refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
795 citations: vec![],
796 document_options: None,
797 };
798
799 let result = format_document(request);
800 match result {
801 Err(FormatDocumentError::UnresolvedInput(_)) => {
802 }
804 _ => panic!("Expected UnresolvedInput error"),
805 }
806 }
807
808 struct MockResolver(Style);
810
811 impl citum_resolver_api::StyleResolver for MockResolver {
812 type Style = Style;
813 type Locale = citum_schema::locale::Locale;
814
815 fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
816 Ok(self.0.clone())
817 }
818
819 fn resolve_locale(
820 &self,
821 id: &str,
822 ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
823 Err(citum_schema::ResolverError::LocaleNotFound(
824 std::borrow::Cow::Owned(id.to_string()),
825 ))
826 }
827 }
828
829 #[test]
830 fn format_document_with_resolver_injects_style_for_id_input() {
831 let style = make_test_style();
832 let resolver = MockResolver(style);
833 let refs = make_test_bibliography();
834
835 let citation_occ = CitationOccurrence {
836 id: "c1".to_string(),
837 items: vec![CitationOccurrenceItem {
838 id: "smith2020".to_string(),
839 locator: None,
840 prefix: None,
841 suffix: None,
842 integral_name_state: None,
843 org_abbreviation_state: None,
844 }],
845 mode: None,
846 note_number: None,
847 suppress_author: None,
848 grouped: None,
849 prefix: None,
850 suffix: None,
851 sentence_start: None,
852 };
853
854 let request = FormatDocumentRequest {
855 style: StyleInput::Id("any-id".to_string()),
856 locale: None,
857 output_format: OutputFormatKind::Plain,
858 refs,
859 citations: vec![citation_occ],
860 document_options: None,
861 };
862
863 match format_document(request.clone()) {
865 Err(FormatDocumentError::UnresolvedInput(_)) => {}
866 other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
867 }
868
869 let result = format_document_with_resolver(request, &resolver);
871 assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
872 let res = result.unwrap();
873 assert_eq!(res.formatted_citations.len(), 1);
874 assert!(
875 !res.formatted_citations[0].text.is_empty(),
876 "formatted citation text should not be empty"
877 );
878 }
879}