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    FormattedBibliographyBlock, FormattedCitation, OutputFormatKind, RefsInput, StyleInput,
33    Warning, WarningLevel,
34};
35
36/// A request to format a complete document's citations and bibliography.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FormatDocumentRequest {
39    /// The style to use (may be resolved locally or by an adapter).
40    pub style: StyleInput,
41    /// Optional partial-style overlay (YAML or JSON) merged over the resolved base
42    /// style for this request only.
43    ///
44    /// Accepts any subset of the style YAML schema — e.g. just `options.contributors`
45    /// to change `and`/et-al behaviour, or a full citation spec. Uses the same
46    /// null-aware, typed-merge semantics as `extends` inheritance: supplied fields
47    /// win over base style fields; an explicit `~` (null) value clears an inherited
48    /// field. The base style is never mutated.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub style_overrides: Option<String>,
51    /// Optional locale override as a BCP 47 language tag (e.g. `en-US`).
52    /// When omitted or set to en-US the engine uses its built-in en-US locale;
53    /// other locales emit a warning and fall back to en-US until adapter-side
54    /// locale resolution is wired through.
55    pub locale: Option<String>,
56    /// Output format (plain, html, djot, latex, typst). Defaults to plain
57    /// when omitted from the request.
58    #[serde(default)]
59    pub output_format: OutputFormatKind,
60    /// Reference input as a local path, inline YAML, inline JSON, or legacy bare map.
61    pub refs: RefsInput,
62    /// Ordered citations as they appear in the document.
63    pub citations: Vec<CitationOccurrence>,
64    /// Ordered sectional bibliography blocks to render after citations.
65    #[serde(default)]
66    pub bibliography_blocks: Vec<super::BibliographyBlockRequest>,
67    /// Optional document-level configuration.
68    pub document_options: Option<DocumentOptions>,
69}
70
71/// The result of formatting a document.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct FormatDocumentResult {
74    /// Formatted citations in document order.
75    pub formatted_citations: Vec<FormattedCitation>,
76    /// Formatted bibliography.
77    pub bibliography: FormattedBibliography,
78    /// Rendered bibliography blocks, in request order.
79    pub bibliography_blocks: Vec<FormattedBibliographyBlock>,
80    /// Non-fatal warnings encountered during processing.
81    pub warnings: Vec<Warning>,
82}
83
84/// Errors that can occur during document formatting.
85#[derive(Debug)]
86pub enum FormatDocumentError {
87    /// The style ID or URI requires a resolver chain not available in the engine.
88    UnresolvedInput(String),
89    /// Failed to parse the style YAML.
90    StyleParse(String),
91    /// Failed to read or locate the style file.
92    StylePath(String),
93    /// Failed to read a local refs input path.
94    RefsInputPath(String),
95    /// Failed to parse refs input data.
96    RefsInputParse(String),
97    /// The processor encountered an error during rendering.
98    Processing(ProcessorError),
99    /// Style inheritance (`extends`) could not be resolved.
100    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
125/// Parse a partial-style overlay (YAML or JSON) and merge it over `style` in place.
126///
127/// Called internally by `format_document_with_style`; also available to surface crates
128/// (e.g. `citum-server`) that pre-resolve the style before handing it to the processor.
129///
130/// Uses the same null-aware, typed-merge semantics as `extends` inheritance.
131/// Calls `apply_scoped_options` after the merge so that overlay fields that affect
132/// scoped options (label_wrap, date_position, repeated_author_rendering, etc.) take
133/// effect in the same way they do during normal style resolution.
134///
135/// # Errors
136///
137/// Returns `FormatDocumentError::StyleParse` if the overlay cannot be parsed.
138pub 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
150/// Format a complete document's citations and bibliography (convenience wrapper).
151///
152/// This function resolves the style locally using `StyleInput::resolve_local`.
153/// For styles requiring a resolver chain (Id or Uri), use `format_document_with_style`
154/// after pre-resolving.
155///
156/// # Errors
157///
158/// Returns an error if the style cannot be resolved, parsed, or if rendering fails.
159pub 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
166/// Format a document, resolving the style through an injected resolver.
167///
168/// `Yaml` is parsed inline; `Id`, `Uri`, and `Path` are delegated to
169/// `resolver.resolve_style`. This lets WASM/FFI callers supply their own
170/// resolver chain without pre-resolving the style themselves.
171///
172/// # Errors
173///
174/// Returns an error if the resolver fails, the style cannot be parsed, or
175/// if rendering fails.
176pub 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    // Fully resolve any `extends` chain via the injected resolver, then clear
187    // `extends` so the processor's later `into_resolved()` call needs no
188    // resolver. Mirrors `citum-server`'s `load_style`.
189    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/// Format a document using an already-resolved style.
197///
198/// This is the primary entry point for adapters (citum-server, citum-bindings)
199/// that have a resolver chain and can pre-resolve style IDs and URIs.
200///
201/// # Errors
202///
203/// Returns an error if rendering fails.
204#[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    // Apply per-request style overrides (merge over the resolved base style).
215    let mut style = style;
216    if let Some(src) = &request.style_overrides {
217        apply_style_overrides(&mut style, src)?;
218    }
219
220    // Locale: the engine has no resolver chain for non-en-US locales.
221    // Adapters with a citum_store dep can pre-resolve and call
222    // Processor::with_locale directly; for now, emit a warning when a
223    // non-en-US tag is requested and fall back to en-US.
224    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        // Rebuild the processor with the document-level integral-name override
246        // before applying scalar field mutations (show_semantics etc.) so that
247        // those mutations are not lost when the processor is reconstructed.
248        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    // Convert citations, recording missing-ref warnings and dropping items
265    // whose reference IDs are absent from the bibliography. Citations with no
266    // surviving items are kept as empty placeholders so the output preserves
267    // input order and length.
268    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    // Annotate integral-name First/Subsequent state from the processor's
289    // effective config (no document structure available; all citations share
290    // document scope). Safe no-op when no memory config is present.
291    processor.annotate_flat_integral_name_states(&mut citations);
292
293    // Process citations
294    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    // Process bibliography
304    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    // Process bibliography blocks
338    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
379/// Scan the bibliography for unknown reference classes and return compatibility warnings.
380pub 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
400/// Scan the style and bibliography for unknown enum variants and term keys.
401///
402/// Returns a list of structured compatibility warnings for encounter of
403/// unknown variants that were captured via the tolerant-enum mechanism.
404pub fn unknown_enum_warnings(processor: &Processor) -> Vec<Warning> {
405    let mut warnings = Vec::new();
406
407    // 1. Scan bibliography
408    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    // 2. Scan Style
471    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
548/// Process citations and return formatted text.
549pub(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
574/// Format the bibliography by output kind.
575pub(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    // Extract annotation map and style if present
584    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    // Render bibliography as string
598    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    // Extract per-entry text in the requested output format and capture metadata.
609    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
643/// Format ordered sectional bibliography blocks.
644///
645/// Threads a single `assigned` dedup set through all blocks so each reference
646/// appears in only one block. Renders entries with annotations if configured.
647pub(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
697/// Extract annotation map and style from document options.
698fn 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
712/// Convert a processor entry to a bibliography entry with annotations.
713fn 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                // Expected
1049            }
1050            _ => panic!("Expected UnresolvedInput error"),
1051        }
1052    }
1053
1054    /// A minimal resolver that returns a fixed style for any ID.
1055    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        // Without a resolver, the same Id input must be rejected.
1112        match format_document(request.clone()) {
1113            Err(FormatDocumentError::UnresolvedInput(_)) => {}
1114            other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
1115        }
1116
1117        // With the injected resolver it must succeed.
1118        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    /// Build an author-date style whose citation template renders contributor short form.
1129    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                // Explicitly set `and: text` so the override to `symbol` is observable
1139                // in rendered output without relying on any default connector.
1140                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    /// Build a refs input with a two-author book so the "and" connector is exercised.
1172    ///
1173    /// Uses inline YAML (the reliably tested deserialization path) rather than
1174    /// round-tripping through `serde_json::to_value` which may not preserve the
1175    /// contributor tagged-enum layout the engine expects.
1176    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    /// Helper: produce a single-item citation occurrence for a given ref id.
1195    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        // given: base style produces a citation containing "and"
1222        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        // when: style_overrides switches connector to symbol "&"
1240        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        // then: base style struct is untouched — still has Text, not Symbol
1259        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    // --- integral_name_memory wiring ---
1315
1316    /// Build a style that has integral-name memory configured with scope=Document,
1317    /// contexts=BodyAndNotes, subsequent_form=Short, and an integral sub-template
1318    /// that renders the author in Long (given + family) form.
1319    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        // With memory disabled both occurrences should render the natural integral
1484        // template form (Long = "John Smith") without any subsequent rewrite.
1485        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        // Style has integral_name_memory in its own options; no document_options
1499        // override is supplied. The flat API must still annotate First/Subsequent.
1500        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}