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::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/// A request to format a complete document's citations and bibliography.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct FormatDocumentRequest {
38    /// The style to use (may be resolved locally or by an adapter).
39    pub style: StyleInput,
40    /// Optional locale override as a BCP 47 language tag (e.g. `en-US`).
41    /// When omitted or set to en-US the engine uses its built-in en-US locale;
42    /// other locales emit a warning and fall back to en-US until adapter-side
43    /// locale resolution is wired through.
44    pub locale: Option<String>,
45    /// Output format (plain, html, djot, latex, typst). Defaults to plain
46    /// when omitted from the request.
47    #[serde(default)]
48    pub output_format: OutputFormatKind,
49    /// Reference input as a local path, inline YAML, inline JSON, or legacy bare map.
50    pub refs: RefsInput,
51    /// Ordered citations as they appear in the document.
52    pub citations: Vec<CitationOccurrence>,
53    /// Optional document-level configuration.
54    pub document_options: Option<DocumentOptions>,
55}
56
57/// The result of formatting a document.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct FormatDocumentResult {
60    /// Formatted citations in document order.
61    pub formatted_citations: Vec<FormattedCitation>,
62    /// Formatted bibliography.
63    pub bibliography: FormattedBibliography,
64    /// Non-fatal warnings encountered during processing.
65    pub warnings: Vec<Warning>,
66}
67
68/// Errors that can occur during document formatting.
69#[derive(Debug)]
70pub enum FormatDocumentError {
71    /// The style ID or URI requires a resolver chain not available in the engine.
72    UnresolvedInput(String),
73    /// Failed to parse the style YAML.
74    StyleParse(String),
75    /// Failed to read or locate the style file.
76    StylePath(String),
77    /// Failed to read a local refs input path.
78    RefsInputPath(String),
79    /// Failed to parse refs input data.
80    RefsInputParse(String),
81    /// The processor encountered an error during rendering.
82    Processing(ProcessorError),
83    /// Style inheritance (`extends`) could not be resolved.
84    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
109/// Format a complete document's citations and bibliography (convenience wrapper).
110///
111/// This function resolves the style locally using `StyleInput::resolve_local`.
112/// For styles requiring a resolver chain (Id or Uri), use `format_document_with_style`
113/// after pre-resolving.
114///
115/// # Errors
116///
117/// Returns an error if the style cannot be resolved, parsed, or if rendering fails.
118pub 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
125/// Format a document, resolving the style through an injected resolver.
126///
127/// `Yaml` is parsed inline; `Id`, `Uri`, and `Path` are delegated to
128/// `resolver.resolve_style`. This lets WASM/FFI callers supply their own
129/// resolver chain without pre-resolving the style themselves.
130///
131/// # Errors
132///
133/// Returns an error if the resolver fails, the style cannot be parsed, or
134/// if rendering fails.
135pub 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    // Fully resolve any `extends` chain via the injected resolver, then clear
146    // `extends` so the processor's later `into_resolved()` call needs no
147    // resolver. Mirrors `citum-server`'s `load_style`.
148    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/// Format a document using an already-resolved style.
156///
157/// This is the primary entry point for adapters (citum-server, citum-bindings)
158/// that have a resolver chain and can pre-resolve style IDs and URIs.
159///
160/// # Errors
161///
162/// Returns an error if rendering fails.
163#[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    // Locale: the engine has no resolver chain for non-en-US locales.
174    // Adapters with a citum_store dep can pre-resolve and call
175    // Processor::with_locale directly; for now, emit a warning when a
176    // non-en-US tag is requested and fall back to en-US.
177    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-wq0y.".to_string(),
214            });
215        }
216    }
217
218    // Convert citations, recording missing-ref warnings and dropping items
219    // whose reference IDs are absent from the bibliography. Citations with no
220    // surviving items are kept as empty placeholders so the output preserves
221    // input order and length.
222    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    // Process citations
243    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    // Process bibliography
253    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
293/// Scan the bibliography for unknown reference classes and return compatibility warnings.
294pub 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
314/// Scan the style and bibliography for unknown enum variants and term keys.
315///
316/// Returns a list of structured compatibility warnings for encounter of
317/// unknown variants that were captured via the tolerant-enum mechanism.
318pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
319    let mut warnings = Vec::new();
320
321    // 1. Scan bibliography
322    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    // 2. Scan Style
385    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
462/// Process citations and return formatted text.
463fn 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
488/// Format the bibliography by output kind.
489fn 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    // Extract annotation map and style if present
498    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    // Render bibliography as string
512    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    // Extract per-entry text in the requested output format and capture metadata.
523    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        };
665
666        let request = FormatDocumentRequest {
667            style: StyleInput::Yaml("dummy".to_string()),
668            locale: None,
669            output_format: OutputFormatKind::Plain,
670            refs,
671            citations: vec![citation_occ],
672            document_options: None,
673        };
674
675        let result = format_document_with_style(style, request);
676        assert!(result.is_ok());
677        let res = result.unwrap();
678        assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
679    }
680
681    #[test]
682    fn format_document_unknown_reference_class_warning() {
683        let style = make_test_style();
684        let mut refs = Bibliography::new();
685        let unknown_ref: InputReference = serde_json::from_str(
686            r#"{
687                "class": "dance-performance",
688                "id": "pina2011",
689                "title": "Pina",
690                "issued": "2011",
691                "venue": "Berlin"
692            }"#,
693        )
694        .expect("unknown class should parse through the compatibility path");
695        refs.insert("pina2011".to_string(), unknown_ref);
696
697        let citation_occ = CitationOccurrence {
698            id: "cite1".to_string(),
699            items: vec![CitationOccurrenceItem {
700                id: "pina2011".to_string(),
701                locator: None,
702                prefix: None,
703                suffix: None,
704                integral_name_state: None,
705                org_abbreviation_state: None,
706            }],
707            mode: None,
708            note_number: None,
709            suppress_author: None,
710            grouped: None,
711            prefix: None,
712            suffix: None,
713        };
714
715        let request = FormatDocumentRequest {
716            style: StyleInput::Yaml("dummy".to_string()),
717            locale: None,
718            output_format: OutputFormatKind::Plain,
719            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
720            citations: vec![citation_occ],
721            document_options: None,
722        };
723
724        let result = format_document_with_style(style, request).unwrap();
725        let warning = result
726            .warnings
727            .iter()
728            .find(|w| w.code == "unknown_reference_class")
729            .expect("unknown class warning should be emitted");
730        assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
731        assert!(warning.message.contains("dance-performance"));
732    }
733
734    #[test]
735    fn format_document_yaml_style_input() {
736        let style = make_test_style();
737        let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
738
739        let mut refs = Bibliography::new();
740        refs.insert(
741            "test2024".to_string(),
742            InputReference::Monograph(Box::new(Monograph {
743                id: Some("test2024".into()),
744                r#type: MonographType::Book,
745                title: Some(Title::Single("Test Work".to_string())),
746                issued: EdtfString("2024".to_string()),
747                ..Default::default()
748            })),
749        );
750
751        let citation_occ = CitationOccurrence {
752            id: "c1".to_string(),
753            items: vec![CitationOccurrenceItem {
754                id: "test2024".to_string(),
755                locator: None,
756                prefix: None,
757                suffix: None,
758                integral_name_state: None,
759                org_abbreviation_state: None,
760            }],
761            mode: None,
762            note_number: None,
763            suppress_author: None,
764            grouped: None,
765            prefix: None,
766            suffix: None,
767        };
768
769        let request = FormatDocumentRequest {
770            style: StyleInput::Yaml(yaml_style),
771            locale: None,
772            output_format: OutputFormatKind::Plain,
773            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
774            citations: vec![citation_occ],
775            document_options: None,
776        };
777
778        let result = format_document(request);
779        assert!(result.is_ok());
780        let res = result.unwrap();
781        assert_eq!(res.formatted_citations.len(), 1);
782        assert!(!res.formatted_citations[0].text.is_empty());
783    }
784
785    #[test]
786    fn format_document_uri_input_unresolved() {
787        let request = FormatDocumentRequest {
788            style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
789            locale: None,
790            output_format: OutputFormatKind::Plain,
791            refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
792            citations: vec![],
793            document_options: None,
794        };
795
796        let result = format_document(request);
797        match result {
798            Err(FormatDocumentError::UnresolvedInput(_)) => {
799                // Expected
800            }
801            _ => panic!("Expected UnresolvedInput error"),
802        }
803    }
804
805    /// A minimal resolver that returns a fixed style for any ID.
806    struct MockResolver(Style);
807
808    impl citum_resolver_api::StyleResolver for MockResolver {
809        type Style = Style;
810        type Locale = citum_schema::locale::Locale;
811
812        fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
813            Ok(self.0.clone())
814        }
815
816        fn resolve_locale(
817            &self,
818            id: &str,
819        ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
820            Err(citum_schema::ResolverError::LocaleNotFound(
821                std::borrow::Cow::Owned(id.to_string()),
822            ))
823        }
824    }
825
826    #[test]
827    fn format_document_with_resolver_injects_style_for_id_input() {
828        let style = make_test_style();
829        let resolver = MockResolver(style);
830        let refs = make_test_bibliography();
831
832        let citation_occ = CitationOccurrence {
833            id: "c1".to_string(),
834            items: vec![CitationOccurrenceItem {
835                id: "smith2020".to_string(),
836                locator: None,
837                prefix: None,
838                suffix: None,
839                integral_name_state: None,
840                org_abbreviation_state: None,
841            }],
842            mode: None,
843            note_number: None,
844            suppress_author: None,
845            grouped: None,
846            prefix: None,
847            suffix: None,
848        };
849
850        let request = FormatDocumentRequest {
851            style: StyleInput::Id("any-id".to_string()),
852            locale: None,
853            output_format: OutputFormatKind::Plain,
854            refs,
855            citations: vec![citation_occ],
856            document_options: None,
857        };
858
859        // Without a resolver, the same Id input must be rejected.
860        match format_document(request.clone()) {
861            Err(FormatDocumentError::UnresolvedInput(_)) => {}
862            other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
863        }
864
865        // With the injected resolver it must succeed.
866        let result = format_document_with_resolver(request, &resolver);
867        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
868        let res = result.unwrap();
869        assert_eq!(res.formatted_citations.len(), 1);
870        assert!(
871            !res.formatted_citations[0].text.is_empty(),
872            "formatted citation text should not be empty"
873        );
874    }
875}