Skip to main content

citum_engine/api/
document.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Document-level batch formatting API.
7
8use 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::plain::PlainText;
17use crate::render::typst::Typst;
18use citum_schema::Style;
19use citum_schema::locale::{GeneralTerm, TermForm};
20use citum_schema::reference::{
21    ClassExtension, CollectionType, ContributorRole as ReferenceRole, MonographComponentType,
22    MonographType, ReferenceClass, SerialComponentType,
23};
24use citum_schema::template::ContributorRole as TemplateRole;
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28
29use super::{
30    BibliographyEntry, CitationOccurrence, DocumentOptions, EntryMetadata, FormattedBibliography,
31    FormattedCitation, OutputFormatKind, RefsInput, StyleInput, Warning, WarningLevel,
32};
33
34/// A request to format a complete document's citations and bibliography.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct FormatDocumentRequest {
37    /// The style to use (may be resolved locally or by an adapter).
38    pub style: StyleInput,
39    /// Optional locale override as a BCP 47 language tag (e.g. `en-US`).
40    /// When omitted or set to en-US the engine uses its built-in en-US locale;
41    /// other locales emit a warning and fall back to en-US until adapter-side
42    /// locale resolution is wired through.
43    pub locale: Option<String>,
44    /// Output format (plain, html, djot, latex, typst). Defaults to plain
45    /// when omitted from the request.
46    #[serde(default)]
47    pub output_format: OutputFormatKind,
48    /// Reference input as a local path, inline YAML, inline JSON, or legacy bare map.
49    pub refs: RefsInput,
50    /// Ordered citations as they appear in the document.
51    pub citations: Vec<CitationOccurrence>,
52    /// Optional document-level configuration.
53    pub document_options: Option<DocumentOptions>,
54}
55
56/// The result of formatting a document.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FormatDocumentResult {
59    /// Formatted citations in document order.
60    pub formatted_citations: Vec<FormattedCitation>,
61    /// Formatted bibliography.
62    pub bibliography: FormattedBibliography,
63    /// Non-fatal warnings encountered during processing.
64    pub warnings: Vec<Warning>,
65}
66
67/// Errors that can occur during document formatting.
68#[derive(Debug)]
69pub enum FormatDocumentError {
70    /// The style ID or URI requires a resolver chain not available in the engine.
71    UnresolvedInput(String),
72    /// Failed to parse the style YAML.
73    StyleParse(String),
74    /// Failed to read or locate the style file.
75    StylePath(String),
76    /// Failed to read a local refs input path.
77    RefsInputPath(String),
78    /// Failed to parse refs input data.
79    RefsInputParse(String),
80    /// The processor encountered an error during rendering.
81    Processing(ProcessorError),
82}
83
84impl std::fmt::Display for FormatDocumentError {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            Self::UnresolvedInput(msg) => write!(f, "Unresolved style input: {}", msg),
88            Self::StyleParse(msg) => write!(f, "Style parse error: {}", msg),
89            Self::StylePath(msg) => write!(f, "Style path error: {}", msg),
90            Self::RefsInputPath(msg) => write!(f, "Refs input path error: {}", msg),
91            Self::RefsInputParse(msg) => write!(f, "Refs input parse error: {}", msg),
92            Self::Processing(err) => write!(f, "Processing error: {}", err),
93        }
94    }
95}
96
97impl std::error::Error for FormatDocumentError {}
98
99impl From<ProcessorError> for FormatDocumentError {
100    fn from(err: ProcessorError) -> Self {
101        Self::Processing(err)
102    }
103}
104
105/// Format a complete document's citations and bibliography (convenience wrapper).
106///
107/// This function resolves the style locally using `StyleInput::resolve_local`.
108/// For styles requiring a resolver chain (Id or Uri), use `format_document_with_style`
109/// after pre-resolving.
110///
111/// # Errors
112///
113/// Returns an error if the style cannot be resolved, parsed, or if rendering fails.
114pub fn format_document(
115    request: FormatDocumentRequest,
116) -> Result<FormatDocumentResult, FormatDocumentError> {
117    let style = request.style.resolve_local()?;
118    format_document_with_style(style, request)
119}
120
121/// Format a document using an already-resolved style.
122///
123/// This is the primary entry point for adapters (citum-server, citum-bindings)
124/// that have a resolver chain and can pre-resolve style IDs and URIs.
125///
126/// # Errors
127///
128/// Returns an error if rendering fails.
129pub fn format_document_with_style(
130    style: Style,
131    request: FormatDocumentRequest,
132) -> Result<FormatDocumentResult, FormatDocumentError> {
133    let mut warnings = Vec::new();
134
135    // Locale: the engine has no resolver chain for non-en-US locales.
136    // Adapters with a citum_store dep can pre-resolve and call
137    // Processor::with_locale directly; for now, emit a warning when a
138    // non-en-US tag is requested and fall back to en-US.
139    if let Some(tag) = &request.locale
140        && !tag.is_empty()
141        && !tag.eq_ignore_ascii_case("en-us")
142    {
143        warnings.push(Warning {
144            level: WarningLevel::Warning,
145            code: "locale_fallback".to_string(),
146            citation_id: None,
147            ref_id: None,
148            message: format!(
149                "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
150            ),
151        });
152    }
153
154    let bibliography = request.refs.resolve_local()?;
155    let mut processor = Processor::new(style, bibliography);
156    warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
157    warnings.extend(unknown_enum_warnings(&processor));
158
159    if let Some(opts) = &request.document_options {
160        if let Some(show_semantics) = opts.show_semantics {
161            processor.show_semantics = show_semantics;
162        }
163        if let Some(inject_ast) = opts.inject_ast_indices {
164            processor.set_inject_ast_indices(inject_ast);
165        }
166        if let Some(abbr_map) = opts.abbreviation_map.clone() {
167            processor.abbreviation_map = Some(abbr_map);
168        }
169        if opts.integral_name_memory.is_some() {
170            warnings.push(Warning {
171                level: WarningLevel::Warning,
172                code: "integral_name_memory_not_applied".to_string(),
173                citation_id: None,
174                ref_id: None,
175                message: "document_options.integral_name_memory is accepted but not yet wired through the processor; tracked in csl26-wq0y.".to_string(),
176            });
177        }
178    }
179
180    // Convert citations, recording missing-ref warnings and dropping items
181    // whose reference IDs are absent from the bibliography. Citations with no
182    // surviving items are kept as empty placeholders so the output preserves
183    // input order and length.
184    let mut citations: Vec<Citation> = Vec::new();
185    for occ in request.citations {
186        let mut citation: Citation = occ.into();
187        citation.items.retain(|item| {
188            if processor.bibliography.contains_key(&item.id) {
189                true
190            } else {
191                warnings.push(Warning {
192                    level: WarningLevel::Warning,
193                    code: "missing_ref".to_string(),
194                    citation_id: citation.id.clone(),
195                    ref_id: Some(item.id.clone()),
196                    message: format!("Reference '{}' not found in bibliography", item.id),
197                });
198                false
199            }
200        });
201        citations.push(citation);
202    }
203
204    // Process citations
205    let formatted_citations = match request.output_format {
206        OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations)?,
207        OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations)?,
208        OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations)?,
209        OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations)?,
210        OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations)?,
211    };
212
213    // Process bibliography
214    let bibliography = match request.output_format {
215        OutputFormatKind::Plain => format_bibliography::<PlainText>(
216            &processor,
217            request.output_format,
218            request.document_options.as_ref(),
219        )?,
220        OutputFormatKind::Html => format_bibliography::<Html>(
221            &processor,
222            request.output_format,
223            request.document_options.as_ref(),
224        )?,
225        OutputFormatKind::Djot => format_bibliography::<Djot>(
226            &processor,
227            request.output_format,
228            request.document_options.as_ref(),
229        )?,
230        OutputFormatKind::Latex => format_bibliography::<Latex>(
231            &processor,
232            request.output_format,
233            request.document_options.as_ref(),
234        )?,
235        OutputFormatKind::Typst => format_bibliography::<Typst>(
236            &processor,
237            request.output_format,
238            request.document_options.as_ref(),
239        )?,
240    };
241
242    Ok(FormatDocumentResult {
243        formatted_citations,
244        bibliography,
245        warnings,
246    })
247}
248
249/// Scan the bibliography for unknown reference classes and return compatibility warnings.
250pub fn unknown_reference_class_warnings(bibliography: &Bibliography) -> Vec<Warning> {
251    bibliography
252        .iter()
253        .filter_map(|(ref_id, reference)| {
254            let ReferenceClass::Unknown(class) = reference.class() else {
255                return None;
256            };
257            Some(Warning {
258                level: WarningLevel::Warning,
259                code: "unknown_reference_class".to_string(),
260                citation_id: None,
261                ref_id: Some(ref_id.clone()),
262                message: format!(
263                    "Reference '{ref_id}' uses unknown class '{class}'; rendering will use only fields this engine understands."
264                ),
265            })
266        })
267        .collect()
268}
269
270/// Scan the style and bibliography for unknown enum variants and term keys.
271///
272/// Returns a list of structured compatibility warnings for encounter of
273/// unknown variants that were captured via the tolerant-enum mechanism.
274pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
275    let mut warnings = Vec::new();
276
277    // 1. Scan bibliography
278    for (ref_id, reference) in &processor.bibliography {
279        match reference.extension() {
280            ClassExtension::Monograph(r) => {
281                if let MonographType::Unknown(s) = &r.r#type {
282                    warnings.push(Warning {
283                        level: WarningLevel::Warning,
284                        code: "unknown_enum_variant".to_string(),
285                        citation_id: None,
286                        ref_id: Some(ref_id.clone()),
287                        message: format!("Reference '{ref_id}' uses unknown monograph type '{s}'; rendering will use default monograph formatting."),
288                    });
289                }
290            }
291            ClassExtension::Collection(r) => {
292                if let CollectionType::Unknown(s) = &r.r#type {
293                    warnings.push(Warning {
294                        level: WarningLevel::Warning,
295                        code: "unknown_enum_variant".to_string(),
296                        citation_id: None,
297                        ref_id: Some(ref_id.clone()),
298                        message: format!("Reference '{ref_id}' uses unknown collection type '{s}'; rendering will use default collection formatting."),
299                    });
300                }
301            }
302            ClassExtension::CollectionComponent(r) => {
303                if let MonographComponentType::Unknown(s) = &r.r#type {
304                    warnings.push(Warning {
305                        level: WarningLevel::Warning,
306                        code: "unknown_enum_variant".to_string(),
307                        citation_id: None,
308                        ref_id: Some(ref_id.clone()),
309                        message: format!("Reference '{ref_id}' uses unknown monograph component type '{s}'; rendering will use default chapter formatting."),
310                    });
311                }
312            }
313            ClassExtension::SerialComponent(r) => {
314                if let SerialComponentType::Unknown(s) = &r.r#type {
315                    warnings.push(Warning {
316                        level: WarningLevel::Warning,
317                        code: "unknown_enum_variant".to_string(),
318                        citation_id: None,
319                        ref_id: Some(ref_id.clone()),
320                        message: format!("Reference '{ref_id}' uses unknown serial component type '{s}'; rendering will use default article formatting."),
321                    });
322                }
323            }
324            _ => {}
325        }
326
327        for contributor in reference.all_contributor_entries() {
328            if let ReferenceRole::Unknown(s) = &contributor.role {
329                warnings.push(Warning {
330                    level: WarningLevel::Warning,
331                    code: "unknown_enum_variant".to_string(),
332                    citation_id: None,
333                    ref_id: Some(ref_id.clone()),
334                    message: format!("Reference '{ref_id}' uses unknown contributor role '{s}'; this role may be ignored during rendering."),
335                });
336            }
337        }
338    }
339
340    // 2. Scan Style
341    if let Some(templates) = &processor.style.templates {
342        for (name, template) in templates {
343            scan_template_for_unknowns(template, &format!("template '{name}'"), &mut warnings);
344        }
345    }
346    if let Some(citation) = &processor.style.citation
347        && let Some(template) = &citation.template
348    {
349        scan_template_for_unknowns(template, "citation layout", &mut warnings);
350    }
351    if let Some(bib) = &processor.style.bibliography
352        && let Some(template) = &bib.template
353    {
354        scan_template_for_unknowns(template, "bibliography layout", &mut warnings);
355    }
356
357    warnings
358}
359
360fn scan_template_for_unknowns(
361    components: &[citum_schema::template::TemplateComponent],
362    location: &str,
363    warnings: &mut Vec<Warning>,
364) {
365    use citum_schema::template::TemplateComponent;
366    for component in components {
367        match component {
368            TemplateComponent::Term(t) => {
369                if let GeneralTerm::Unknown(s) = &t.term {
370                    warnings.push(Warning {
371                        level: WarningLevel::Warning,
372                        code: "unknown_enum_variant".to_string(),
373                        citation_id: None,
374                        ref_id: None,
375                        message: format!("Style {location} uses unknown locale term key '{s}'; this term may render as empty."),
376                    });
377                }
378                if let Some(TermForm::Unknown(s)) = &t.form {
379                    warnings.push(Warning {
380                        level: WarningLevel::Warning,
381                        code: "unknown_enum_variant".to_string(),
382                        citation_id: None,
383                        ref_id: None,
384                        message: format!("Style {location} uses unknown term form '{s}'; falling back to long form."),
385                    });
386                }
387            }
388            TemplateComponent::Contributor(c) => {
389                if let TemplateRole::Unknown(s) = &c.contributor {
390                    warnings.push(Warning {
391                        level: WarningLevel::Warning,
392                        code: "unknown_enum_variant".to_string(),
393                        citation_id: None,
394                        ref_id: None,
395                        message: format!("Style {location} uses unknown contributor role '{s}'; this role may be ignored."),
396                    });
397                }
398            }
399            TemplateComponent::Date(d) => {
400                if let citum_schema::template::DateForm::Unknown(s) = &d.form {
401                    warnings.push(Warning {
402                        level: WarningLevel::Warning,
403                        code: "unknown_enum_variant".to_string(),
404                        citation_id: None,
405                        ref_id: None,
406                        message: format!("Style {location} uses unknown date form '{s}'; falling back to year only."),
407                    });
408                }
409            }
410            TemplateComponent::Group(g) => {
411                scan_template_for_unknowns(&g.group, location, warnings);
412            }
413            _ => {}
414        }
415    }
416}
417
418/// Process citations and return formatted text.
419fn format_by_kind<F>(
420    processor: &Processor,
421    citations: &[Citation],
422) -> Result<Vec<FormattedCitation>, FormatDocumentError>
423where
424    F: OutputFormat<Output = String>,
425{
426    let texts = processor.process_citations_with_format::<F>(citations)?;
427
428    let formatted = citations
429        .iter()
430        .zip(texts.iter())
431        .map(|(citation, text)| {
432            let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
433            FormattedCitation {
434                id: citation.id.clone().unwrap_or_default(),
435                text: text.clone(),
436                ref_ids,
437            }
438        })
439        .collect();
440
441    Ok(formatted)
442}
443
444/// Format the bibliography by output kind.
445fn format_bibliography<F>(
446    processor: &Processor,
447    format_kind: OutputFormatKind,
448    doc_opts: Option<&DocumentOptions>,
449) -> Result<FormattedBibliography, FormatDocumentError>
450where
451    F: OutputFormat<Output = String>,
452{
453    // Extract annotation map and style if present
454    let (annotations, annotation_style) = if let Some(opts) = doc_opts {
455        if let Some(anns) = &opts.annotations {
456            let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
457                format: fmt.clone(),
458            });
459            (anns.clone(), style)
460        } else {
461            (HashMap::new(), None)
462        }
463    } else {
464        (HashMap::new(), None)
465    };
466
467    // Render bibliography as string
468    let content = if annotations.is_empty() {
469        processor
470            .render_bibliography_with_format_and_annotations::<F>(None, annotation_style.as_ref())
471    } else {
472        processor.render_bibliography_with_format_and_annotations::<F>(
473            Some(&annotations),
474            annotation_style.as_ref(),
475        )
476    };
477
478    // Extract per-entry text in the requested output format and capture metadata.
479    let proc_entries = processor.process_references().bibliography;
480    let entries = proc_entries
481        .into_iter()
482        .map(|entry| {
483            let entry_anns = if annotations.is_empty() {
484                None
485            } else {
486                Some(&annotations)
487            };
488            let text = crate::render::bibliography::refs_to_string_with_format::<F>(
489                vec![entry.clone()],
490                entry_anns,
491                annotation_style.as_ref(),
492            );
493            let metadata = EntryMetadata {
494                author: entry.metadata.author.unwrap_or_default(),
495                year: entry.metadata.year.unwrap_or_default(),
496                title: entry.metadata.title.unwrap_or_default(),
497            };
498            BibliographyEntry {
499                id: entry.id,
500                text,
501                metadata,
502            }
503        })
504        .collect();
505
506    Ok(FormattedBibliography {
507        format: format_kind,
508        content,
509        entries,
510    })
511}
512
513#[cfg(test)]
514#[allow(
515    clippy::unwrap_used,
516    clippy::expect_used,
517    clippy::panic,
518    clippy::indexing_slicing,
519    reason = "test code uses assertions and panic"
520)]
521mod tests {
522    use super::*;
523    use crate::api::CitationOccurrenceItem;
524    use crate::{
525        Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
526        TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
527        WrapPunctuation,
528    };
529    use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
530    use citum_schema::{CitationSpec, StyleInfo};
531
532    fn make_test_style() -> Style {
533        Style {
534            info: StyleInfo {
535                title: Some("Test Style".to_string()),
536                id: Some("test".into()),
537                ..Default::default()
538            },
539            options: Some(Config {
540                processing: Some(Processing::AuthorDate),
541                ..Default::default()
542            }),
543            citation: Some(CitationSpec {
544                template: Some(vec![
545                    TemplateComponent::Contributor(TemplateContributor {
546                        contributor: ContributorRole::Author,
547                        form: ContributorForm::Short,
548                        rendering: Rendering::default(),
549                        ..Default::default()
550                    }),
551                    TemplateComponent::Date(TemplateDate {
552                        date: TemplateDateVariable::Issued,
553                        form: DateForm::Year,
554                        rendering: Rendering::default(),
555                        ..Default::default()
556                    }),
557                ]),
558                wrap: Some(WrapPunctuation::Parentheses.into()),
559                ..Default::default()
560            }),
561            ..Default::default()
562        }
563    }
564
565    fn make_test_bibliography() -> RefsInput {
566        let mut refs = Bibliography::new();
567        refs.insert(
568            "smith2020".to_string(),
569            InputReference::Monograph(Box::new(Monograph {
570                id: Some("smith2020".into()),
571                r#type: MonographType::Book,
572                title: Some(Title::Single("Sample Work".to_string())),
573                issued: EdtfString("2020".to_string()),
574                ..Default::default()
575            })),
576        );
577        RefsInput::Json(serde_json::to_value(refs).unwrap())
578    }
579
580    #[test]
581    fn format_document_with_style_empty_citations() {
582        let style = make_test_style();
583        let refs = make_test_bibliography();
584        let request = FormatDocumentRequest {
585            style: StyleInput::Yaml("dummy".to_string()),
586            locale: None,
587            output_format: OutputFormatKind::Plain,
588            refs,
589            citations: vec![],
590            document_options: None,
591        };
592
593        let result = format_document_with_style(style, request);
594        assert!(result.is_ok());
595        let res = result.unwrap();
596        assert_eq!(res.formatted_citations.len(), 0);
597    }
598
599    #[test]
600    fn format_document_missing_ref_warning() {
601        let style = make_test_style();
602        let refs = make_test_bibliography();
603
604        let citation_occ = CitationOccurrence {
605            id: "cite1".to_string(),
606            items: vec![CitationOccurrenceItem {
607                id: "unknown_ref".to_string(),
608                locator: None,
609                prefix: None,
610                suffix: None,
611                integral_name_state: None,
612                org_abbreviation_state: None,
613            }],
614            mode: None,
615            note_number: None,
616            suppress_author: None,
617            grouped: None,
618            prefix: None,
619            suffix: None,
620        };
621
622        let request = FormatDocumentRequest {
623            style: StyleInput::Yaml("dummy".to_string()),
624            locale: None,
625            output_format: OutputFormatKind::Plain,
626            refs,
627            citations: vec![citation_occ],
628            document_options: None,
629        };
630
631        let result = format_document_with_style(style, request);
632        assert!(result.is_ok());
633        let res = result.unwrap();
634        assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
635    }
636
637    #[test]
638    fn format_document_unknown_reference_class_warning() {
639        let style = make_test_style();
640        let mut refs = Bibliography::new();
641        let unknown_ref: InputReference = serde_json::from_str(
642            r#"{
643                "class": "dance-performance",
644                "id": "pina2011",
645                "title": "Pina",
646                "issued": "2011",
647                "venue": "Berlin"
648            }"#,
649        )
650        .expect("unknown class should parse through the compatibility path");
651        refs.insert("pina2011".to_string(), unknown_ref);
652
653        let citation_occ = CitationOccurrence {
654            id: "cite1".to_string(),
655            items: vec![CitationOccurrenceItem {
656                id: "pina2011".to_string(),
657                locator: None,
658                prefix: None,
659                suffix: None,
660                integral_name_state: None,
661                org_abbreviation_state: None,
662            }],
663            mode: None,
664            note_number: None,
665            suppress_author: None,
666            grouped: None,
667            prefix: None,
668            suffix: None,
669        };
670
671        let request = FormatDocumentRequest {
672            style: StyleInput::Yaml("dummy".to_string()),
673            locale: None,
674            output_format: OutputFormatKind::Plain,
675            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
676            citations: vec![citation_occ],
677            document_options: None,
678        };
679
680        let result = format_document_with_style(style, request).unwrap();
681        let warning = result
682            .warnings
683            .iter()
684            .find(|w| w.code == "unknown_reference_class")
685            .expect("unknown class warning should be emitted");
686        assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
687        assert!(warning.message.contains("dance-performance"));
688    }
689
690    #[test]
691    fn format_document_yaml_style_input() {
692        let style = make_test_style();
693        let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
694
695        let mut refs = Bibliography::new();
696        refs.insert(
697            "test2024".to_string(),
698            InputReference::Monograph(Box::new(Monograph {
699                id: Some("test2024".into()),
700                r#type: MonographType::Book,
701                title: Some(Title::Single("Test Work".to_string())),
702                issued: EdtfString("2024".to_string()),
703                ..Default::default()
704            })),
705        );
706
707        let citation_occ = CitationOccurrence {
708            id: "c1".to_string(),
709            items: vec![CitationOccurrenceItem {
710                id: "test2024".to_string(),
711                locator: None,
712                prefix: None,
713                suffix: None,
714                integral_name_state: None,
715                org_abbreviation_state: None,
716            }],
717            mode: None,
718            note_number: None,
719            suppress_author: None,
720            grouped: None,
721            prefix: None,
722            suffix: None,
723        };
724
725        let request = FormatDocumentRequest {
726            style: StyleInput::Yaml(yaml_style),
727            locale: None,
728            output_format: OutputFormatKind::Plain,
729            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
730            citations: vec![citation_occ],
731            document_options: None,
732        };
733
734        let result = format_document(request);
735        assert!(result.is_ok());
736        let res = result.unwrap();
737        assert_eq!(res.formatted_citations.len(), 1);
738        assert!(!res.formatted_citations[0].text.is_empty());
739    }
740
741    #[test]
742    fn format_document_uri_input_unresolved() {
743        let request = FormatDocumentRequest {
744            style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
745            locale: None,
746            output_format: OutputFormatKind::Plain,
747            refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
748            citations: vec![],
749            document_options: None,
750        };
751
752        let result = format_document(request);
753        match result {
754            Err(FormatDocumentError::UnresolvedInput(_)) => {
755                // Expected
756            }
757            _ => panic!("Expected UnresolvedInput error"),
758        }
759    }
760}