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::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;
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23
24use super::warnings::{
25    unknown_enum_warnings, unknown_reference_class_warnings, unknown_reference_field_warnings,
26};
27use super::{
28    BibliographyEntry, CitationOccurrence, DocumentOptions, EntryMetadata, FormattedBibliography,
29    FormattedBibliographyBlock, FormattedCitation, OutputFormatKind, RefsInput, StyleInput,
30    Warning, WarningLevel,
31};
32
33/// A request to format a complete document's citations and bibliography.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct FormatDocumentRequest {
36    /// The style to use (may be resolved locally or by an adapter).
37    pub style: StyleInput,
38    /// Optional partial-style overlay (YAML or JSON) merged over the resolved base
39    /// style for this request only.
40    ///
41    /// Accepts any subset of the style YAML schema — e.g. just `options.contributors`
42    /// to change `and`/et-al behaviour, or a full citation spec. Uses the same
43    /// null-aware, typed-merge semantics as `extends` inheritance: supplied fields
44    /// win over base style fields; an explicit `~` (null) value clears an inherited
45    /// field. The base style is never mutated.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub style_overrides: Option<String>,
48    /// Optional locale override as a BCP 47 language tag (e.g. `en-US`).
49    /// When omitted or set to en-US the engine uses its built-in en-US locale;
50    /// other locales emit a warning and fall back to en-US until adapter-side
51    /// locale resolution is wired through.
52    pub locale: Option<String>,
53    /// Output format (plain, html, djot, latex, typst). Defaults to plain
54    /// when omitted from the request.
55    #[serde(default)]
56    pub output_format: OutputFormatKind,
57    /// Reference input as a local path, inline YAML, inline JSON, or legacy bare map.
58    pub refs: RefsInput,
59    /// Ordered citations as they appear in the document.
60    pub citations: Vec<CitationOccurrence>,
61    /// Ordered sectional bibliography blocks to render after citations.
62    #[serde(default)]
63    pub bibliography_blocks: Vec<super::BibliographyBlockRequest>,
64    /// Optional document-level configuration.
65    pub document_options: Option<DocumentOptions>,
66    /// Reference IDs to include in the bibliography without emitting an in-text citation.
67    ///
68    /// Nocite entries appear in `bibliography.entries` (and match `CitedStatus::Visible`
69    /// selectors for grouped / block bibliographies) but produce no `formatted_citations`
70    /// entry. This matches standard citeproc / Pandoc `nocite` semantics.
71    ///
72    /// IDs absent from `refs` are ignored and trigger a `nocite_missing_ref` warning.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub nocite: Vec<String>,
75}
76
77/// The result of formatting a document.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct FormatDocumentResult {
80    /// Formatted citations in document order.
81    pub formatted_citations: Vec<FormattedCitation>,
82    /// Formatted bibliography.
83    pub bibliography: FormattedBibliography,
84    /// Rendered bibliography blocks, in request order.
85    pub bibliography_blocks: Vec<FormattedBibliographyBlock>,
86    /// Non-fatal warnings encountered during processing.
87    pub warnings: Vec<Warning>,
88}
89
90/// Errors that can occur during document formatting.
91#[derive(thiserror::Error, Debug)]
92pub enum FormatDocumentError {
93    /// The style ID or URI requires a resolver chain not available in the engine.
94    #[error("Unresolved style input: {0}")]
95    UnresolvedInput(String),
96    /// Failed to parse the style YAML.
97    #[error("Style parse error: {0}")]
98    StyleParse(String),
99    /// Failed to read or locate the style file.
100    #[error("Style path error: {0}")]
101    StylePath(String),
102    /// Failed to read a local refs input path.
103    #[error("Refs input path error: {0}")]
104    RefsInputPath(String),
105    /// Failed to parse refs input data.
106    #[error("Refs input parse error: {0}")]
107    RefsInputParse(String),
108    /// The processor encountered an error during rendering.
109    #[error("Processing error: {0}")]
110    Processing(#[from] ProcessorError),
111    /// Style inheritance (`extends`) could not be resolved.
112    #[error("Style resolution error: {0}")]
113    StyleResolution(String),
114}
115
116/// Parse a partial-style overlay (YAML or JSON) and merge it over `style` in place.
117///
118/// Called internally by `format_document_with_style`; also available to surface crates
119/// (e.g. `citum-server`) that pre-resolve the style before handing it to the processor.
120///
121/// Uses the same null-aware, typed-merge semantics as `extends` inheritance.
122/// Calls `apply_scoped_options` after the merge so that overlay fields that affect
123/// scoped options (label_wrap, date_position, repeated_author_rendering, etc.) take
124/// effect in the same way they do during normal style resolution.
125///
126/// # Errors
127///
128/// Returns `FormatDocumentError::StyleParse` if the overlay cannot be parsed.
129pub fn apply_style_overrides(
130    style: &mut Style,
131    overlay_src: &str,
132) -> Result<(), FormatDocumentError> {
133    let overlay = Style::from_yaml_bytes(overlay_src.as_bytes()).map_err(|e| {
134        FormatDocumentError::StyleParse(format!("Failed to parse style_overrides: {e}"))
135    })?;
136    style.apply_overlay(&overlay);
137    style.apply_scoped_options();
138    Ok(())
139}
140
141/// Format a complete document's citations and bibliography (convenience wrapper).
142///
143/// This function resolves the style locally using `StyleInput::resolve_local`.
144/// For styles requiring a resolver chain (Id or Uri), use `format_document_with_style`
145/// after pre-resolving.
146///
147/// # Errors
148///
149/// Returns an error if the style cannot be resolved, parsed, or if rendering fails.
150pub fn format_document(
151    request: FormatDocumentRequest,
152) -> Result<FormatDocumentResult, FormatDocumentError> {
153    let style = request.style.resolve_local()?;
154    format_document_with_style(style, request)
155}
156
157/// Format a document, resolving the style through an injected resolver.
158///
159/// `Yaml` is parsed inline; `Id`, `Uri`, and `Path` are delegated to
160/// `resolver.resolve_style`. This lets WASM/FFI callers supply their own
161/// resolver chain without pre-resolving the style themselves.
162///
163/// # Errors
164///
165/// Returns an error if the resolver fails, the style cannot be parsed, or
166/// if rendering fails.
167pub fn format_document_with_resolver(
168    request: FormatDocumentRequest,
169    resolver: &citum_schema::StyleResolver,
170) -> Result<FormatDocumentResult, FormatDocumentError> {
171    let style = match &request.style {
172        StyleInput::Yaml(_) => request.style.resolve_local()?,
173        StyleInput::Id(value) | StyleInput::Uri(value) | StyleInput::Path(value) => resolver
174            .resolve_style(value)
175            .map_err(|e| FormatDocumentError::UnresolvedInput(e.to_string()))?,
176    };
177    // Fully resolve any `extends` chain via the injected resolver, then clear
178    // `extends` so the processor's later `into_resolved()` call needs no
179    // resolver. Mirrors `citum-server`'s `load_style`.
180    let mut resolved = style
181        .try_into_resolved_with(Some(resolver))
182        .map_err(|e| FormatDocumentError::StyleResolution(e.to_string()))?;
183    resolved.extends = None;
184    format_document_with_style(resolved, request)
185}
186
187/// Format a document using an already-resolved style.
188///
189/// This is the primary entry point for adapters (citum-server, citum-bindings)
190/// that have a resolver chain and can pre-resolve style IDs and URIs.
191///
192/// # Errors
193///
194/// Returns an error if rendering fails.
195#[allow(
196    clippy::too_many_lines,
197    reason = "match arms grow one-to-one with format variants"
198)]
199pub fn format_document_with_style(
200    style: Style,
201    request: FormatDocumentRequest,
202) -> Result<FormatDocumentResult, FormatDocumentError> {
203    let mut warnings = Vec::new();
204
205    // Apply per-request style overrides (merge over the resolved base style).
206    let mut style = style;
207    if let Some(src) = &request.style_overrides {
208        apply_style_overrides(&mut style, src)?;
209    }
210
211    // Locale: the engine has no resolver chain for non-en-US locales.
212    // Adapters with a citum_store dep can pre-resolve and call
213    // Processor::with_locale directly; for now, emit a warning when a
214    // non-en-US tag is requested and fall back to en-US.
215    if let Some(tag) = &request.locale
216        && !tag.is_empty()
217        && !tag.eq_ignore_ascii_case("en-us")
218    {
219        warnings.push(Warning {
220            level: WarningLevel::Warning,
221            code: "locale_fallback".to_string(),
222            citation_id: None,
223            ref_id: None,
224            message: format!(
225                "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
226            ),
227        });
228    }
229
230    let bibliography = request.refs.resolve_local()?;
231    let mut processor = Processor::new(style, bibliography);
232    warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
233    warnings.extend(unknown_reference_field_warnings(&processor.bibliography));
234    warnings.extend(unknown_enum_warnings(&processor));
235
236    if let Some(opts) = &request.document_options {
237        // Rebuild the processor with the document-level integral-name override
238        // before applying scalar field mutations (show_semantics etc.) so that
239        // those mutations are not lost when the processor is reconstructed.
240        if let Some(new_proc) = processor
241            .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
242        {
243            processor = new_proc;
244        }
245        if let Some(show_semantics) = opts.show_semantics {
246            processor.show_semantics = show_semantics;
247        }
248        if let Some(inject_ast) = opts.inject_ast_indices {
249            processor.set_inject_ast_indices(inject_ast);
250        }
251        if let Some(abbr_map) = opts.abbreviation_map.clone() {
252            processor.abbreviation_map = Some(abbr_map);
253        }
254    }
255
256    // Convert citations, recording missing-ref warnings and dropping items
257    // whose reference IDs are absent from the bibliography. Citations with no
258    // surviving items are kept as empty placeholders so the output preserves
259    // input order and length.
260    let mut citations: Vec<Citation> = Vec::new();
261    for occ in request.citations {
262        let mut citation: Citation = occ.into();
263        citation.items.retain(|item| {
264            if processor.bibliography.contains_key(&item.id) {
265                true
266            } else {
267                warnings.push(Warning {
268                    level: WarningLevel::Warning,
269                    code: "missing_ref".to_string(),
270                    citation_id: citation.id.clone(),
271                    ref_id: Some(item.id.clone()),
272                    message: format!("Reference '{}' not found in bibliography", item.id),
273                });
274                false
275            }
276        });
277        citations.push(citation);
278    }
279
280    // Annotate integral-name First/Subsequent state from the processor's
281    // effective config (no document structure available; all citations share
282    // document scope). Safe no-op when no memory config is present.
283    processor.annotate_flat_integral_name_states(&mut citations);
284
285    // One run threads registration (citation numbers, cite-order, dynamic
286    // compound groups, first-note tracking) across citations and nocite
287    // registration below, then finalizes once for bibliography rendering.
288    let mut run = processor.begin_run();
289
290    // Process citations
291    let formatted_citations = match request.output_format {
292        OutputFormatKind::Plain => format_by_kind::<PlainText>(&processor, &citations, &mut run)?,
293        OutputFormatKind::Html => format_by_kind::<Html>(&processor, &citations, &mut run)?,
294        OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &citations, &mut run)?,
295        OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &citations, &mut run)?,
296        OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &citations, &mut run)?,
297        OutputFormatKind::Markdown => format_by_kind::<Markdown>(&processor, &citations, &mut run)?,
298    };
299
300    // Register nocite IDs: validate against bibliography, warn on missing, then add
301    // to cited_ids so they appear in bibliography.entries but produce no citation text.
302    let nocite_ids: Vec<String> = request
303        .nocite
304        .iter()
305        .filter_map(|id| {
306            if processor.bibliography.contains_key(id) {
307                Some(id.clone())
308            } else {
309                warnings.push(Warning {
310                    level: WarningLevel::Warning,
311                    code: "nocite_missing_ref".to_string(),
312                    citation_id: None,
313                    ref_id: Some(id.clone()),
314                    message: format!("Nocite reference '{id}' not found in bibliography"),
315                });
316                None
317            }
318        })
319        .collect();
320    processor.register_nocite_ids(nocite_ids, &mut run);
321
322    // Registration is complete: finalize so bibliography rendering below sees
323    // the full cite-order state from every citation processed above.
324    let run = run.finalize();
325
326    // Process bibliography
327    let bibliography = match request.output_format {
328        OutputFormatKind::Plain => format_bibliography::<PlainText>(
329            &processor,
330            request.output_format,
331            request.document_options.as_ref(),
332            &run,
333        )?,
334        OutputFormatKind::Html => format_bibliography::<Html>(
335            &processor,
336            request.output_format,
337            request.document_options.as_ref(),
338            &run,
339        )?,
340        OutputFormatKind::Djot => format_bibliography::<Djot>(
341            &processor,
342            request.output_format,
343            request.document_options.as_ref(),
344            &run,
345        )?,
346        OutputFormatKind::Latex => format_bibliography::<Latex>(
347            &processor,
348            request.output_format,
349            request.document_options.as_ref(),
350            &run,
351        )?,
352        OutputFormatKind::Typst => format_bibliography::<Typst>(
353            &processor,
354            request.output_format,
355            request.document_options.as_ref(),
356            &run,
357        )?,
358        OutputFormatKind::Markdown => format_bibliography::<Markdown>(
359            &processor,
360            request.output_format,
361            request.document_options.as_ref(),
362            &run,
363        )?,
364    };
365
366    // Process bibliography blocks
367    let bibliography_blocks = match request.output_format {
368        OutputFormatKind::Plain => format_bibliography_blocks::<PlainText>(
369            &processor,
370            &request.bibliography_blocks,
371            request.document_options.as_ref(),
372            &run,
373        )?,
374        OutputFormatKind::Html => format_bibliography_blocks::<Html>(
375            &processor,
376            &request.bibliography_blocks,
377            request.document_options.as_ref(),
378            &run,
379        )?,
380        OutputFormatKind::Djot => format_bibliography_blocks::<Djot>(
381            &processor,
382            &request.bibliography_blocks,
383            request.document_options.as_ref(),
384            &run,
385        )?,
386        OutputFormatKind::Latex => format_bibliography_blocks::<Latex>(
387            &processor,
388            &request.bibliography_blocks,
389            request.document_options.as_ref(),
390            &run,
391        )?,
392        OutputFormatKind::Typst => format_bibliography_blocks::<Typst>(
393            &processor,
394            &request.bibliography_blocks,
395            request.document_options.as_ref(),
396            &run,
397        )?,
398        OutputFormatKind::Markdown => format_bibliography_blocks::<Markdown>(
399            &processor,
400            &request.bibliography_blocks,
401            request.document_options.as_ref(),
402            &run,
403        )?,
404    };
405
406    Ok(FormatDocumentResult {
407        formatted_citations,
408        bibliography,
409        bibliography_blocks,
410        warnings,
411    })
412}
413
414/// Process citations and return formatted text.
415pub(crate) fn format_by_kind<F>(
416    processor: &Processor,
417    citations: &[Citation],
418    run: &mut crate::processor::RunState,
419) -> Result<Vec<FormattedCitation>, FormatDocumentError>
420where
421    F: OutputFormat<Output = String>,
422{
423    let texts = processor.process_citations_with_format::<F>(citations, run)?;
424
425    let formatted = citations
426        .iter()
427        .zip(texts.iter())
428        .map(|(citation, text)| {
429            let ref_ids = citation.items.iter().map(|item| item.id.clone()).collect();
430            FormattedCitation {
431                id: citation.id.clone().unwrap_or_default(),
432                text: text.clone(),
433                ref_ids,
434            }
435        })
436        .collect();
437
438    Ok(formatted)
439}
440
441/// Format the bibliography by output kind, restricted to the document's cited set.
442///
443/// Only references that appear in `processor.cited_ids` — either via an in-text
444/// citation or via a `nocite` registration — are included in the output. Delegates
445/// to [`Processor::render_document_bibliography`], the unified facade that ensures
446/// both `content` and `entries` are computed from the same cited subset so
447/// subsequent-author substitution stays consistent.
448pub(crate) fn format_bibliography<F>(
449    processor: &Processor,
450    format_kind: OutputFormatKind,
451    doc_opts: Option<&DocumentOptions>,
452    run: &crate::processor::FinalizedRun,
453) -> Result<FormattedBibliography, FormatDocumentError>
454where
455    F: OutputFormat<Output = String>,
456{
457    let (annotations, annotation_style) = annotation_options(doc_opts);
458    let doc_bib = processor.render_document_bibliography::<F>(
459        true,
460        if annotations.is_empty() {
461            None
462        } else {
463            Some(&annotations)
464        },
465        annotation_style.as_ref(),
466        run,
467    );
468    let entries = doc_bib
469        .entries
470        .into_iter()
471        .map(|entry| {
472            proc_entry_to_bibliography_entry::<F>(
473                entry,
474                if annotations.is_empty() {
475                    None
476                } else {
477                    Some(&annotations)
478                },
479                annotation_style.as_ref(),
480            )
481        })
482        .collect();
483    Ok(FormattedBibliography {
484        format: format_kind,
485        content: doc_bib.content,
486        entries,
487    })
488}
489
490/// Format ordered sectional bibliography blocks.
491///
492/// Threads a single `assigned` dedup set through all blocks so each reference
493/// appears in only one block. Renders entries with annotations if configured.
494pub(crate) fn format_bibliography_blocks<F>(
495    processor: &Processor,
496    requests: &[super::BibliographyBlockRequest],
497    doc_opts: Option<&DocumentOptions>,
498    run: &crate::processor::FinalizedRun,
499) -> Result<Vec<super::FormattedBibliographyBlock>, FormatDocumentError>
500where
501    F: OutputFormat<Output = String>,
502{
503    if requests.is_empty() {
504        return Ok(Vec::new());
505    }
506
507    let (annotations, annotation_style) = annotation_options(doc_opts);
508    let groups: Vec<_> = requests.iter().map(|r| r.group.clone()).collect();
509    let rendered = processor.render_document_bibliography_blocks::<F>(
510        &groups,
511        if annotations.is_empty() {
512            None
513        } else {
514            Some(&annotations)
515        },
516        annotation_style.as_ref(),
517        run,
518    );
519
520    Ok(requests
521        .iter()
522        .zip(rendered)
523        .map(|(req, rg)| super::FormattedBibliographyBlock {
524            id: req.id.clone(),
525            heading: rg.heading,
526            content: rg.body,
527            entries: rg
528                .entries
529                .into_iter()
530                .map(|entry| {
531                    proc_entry_to_bibliography_entry::<F>(
532                        entry,
533                        if annotations.is_empty() {
534                            None
535                        } else {
536                            Some(&annotations)
537                        },
538                        annotation_style.as_ref(),
539                    )
540                })
541                .collect(),
542        })
543        .collect())
544}
545
546/// Extract annotation map and style from document options.
547fn annotation_options(
548    doc_opts: Option<&DocumentOptions>,
549) -> (HashMap<String, String>, Option<AnnotationStyle>) {
550    if let Some(opts) = doc_opts
551        && let Some(anns) = &opts.annotations
552    {
553        let style = opts.annotation_format.as_ref().map(|fmt| AnnotationStyle {
554            format: fmt.clone(),
555        });
556        return (anns.clone(), style);
557    }
558    (HashMap::new(), None)
559}
560
561/// Convert a processor entry to a bibliography entry with annotations.
562fn proc_entry_to_bibliography_entry<F>(
563    entry: crate::render::ProcEntry,
564    annotations: Option<&HashMap<String, String>>,
565    annotation_style: Option<&AnnotationStyle>,
566) -> BibliographyEntry
567where
568    F: OutputFormat<Output = String>,
569{
570    let text = crate::render::bibliography::refs_to_string_slice_with_format::<F>(
571        std::slice::from_ref(&entry),
572        annotations,
573        annotation_style,
574    );
575    let metadata = EntryMetadata {
576        author: entry.metadata.author.unwrap_or_default(),
577        year: entry.metadata.year.unwrap_or_default(),
578        title: entry.metadata.title.unwrap_or_default(),
579    };
580    BibliographyEntry {
581        id: entry.id,
582        text,
583        metadata,
584    }
585}
586
587#[cfg(test)]
588#[allow(
589    clippy::unwrap_used,
590    clippy::expect_used,
591    clippy::panic,
592    clippy::indexing_slicing,
593    reason = "test code uses assertions and panic"
594)]
595mod tests {
596    use super::*;
597    use crate::api::CitationOccurrenceItem;
598    use crate::reference::Bibliography;
599    use crate::{
600        Config, ContributorForm, ContributorRole, DateForm, Processing, Rendering,
601        TemplateComponent, TemplateContributor, TemplateDate, TemplateDateVariable,
602        WrapPunctuation,
603    };
604    use citum_schema::data::citation::CitationMode;
605    use citum_schema::options::{AndOptions, ContributorConfig};
606    use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
607    use citum_schema::template::{TemplateTitle, TitleType};
608    use citum_schema::{BibliographySpec, CitationSpec, StyleInfo};
609    use std::collections::HashMap;
610
611    fn make_test_style() -> Style {
612        Style {
613            info: StyleInfo {
614                title: Some("Test Style".to_string()),
615                id: Some("test".into()),
616                ..Default::default()
617            },
618            options: Some(Config {
619                processing: Some(Processing::AuthorDate),
620                ..Default::default()
621            }),
622            citation: Some(CitationSpec {
623                template: Some(vec![
624                    TemplateComponent::Contributor(TemplateContributor {
625                        contributor: ContributorRole::Author.into(),
626                        form: ContributorForm::Short,
627                        rendering: Rendering::default(),
628                        ..Default::default()
629                    }),
630                    TemplateComponent::Date(TemplateDate {
631                        date: TemplateDateVariable::Issued,
632                        form: DateForm::Year,
633                        rendering: Rendering::default(),
634                        ..Default::default()
635                    }),
636                ]),
637                wrap: Some(WrapPunctuation::Parentheses.into()),
638                ..Default::default()
639            }),
640            ..Default::default()
641        }
642    }
643
644    fn make_test_bibliography() -> RefsInput {
645        let mut refs = Bibliography::new();
646        refs.insert(
647            "smith2020".to_string(),
648            InputReference::Monograph(Box::new(Monograph {
649                id: Some("smith2020".into()),
650                r#type: MonographType::Book,
651                title: Some(Title::Single("Sample Work".to_string())),
652                issued: EdtfString("2020".to_string()),
653                ..Default::default()
654            })),
655        );
656        RefsInput::Json(serde_json::to_value(refs).unwrap())
657    }
658
659    fn make_markup_bibliography() -> RefsInput {
660        let mut refs = Bibliography::new();
661        refs.insert(
662            "art1".to_string(),
663            InputReference::Monograph(Box::new(Monograph {
664                id: Some("art1".into()),
665                r#type: MonographType::Book,
666                title: Some(Title::Single(
667                    "_Homo sapiens_ and *modern* world".to_string(),
668                )),
669                issued: EdtfString("2023".to_string()),
670                ..Default::default()
671            })),
672        );
673        RefsInput::Json(serde_json::to_value(refs).unwrap())
674    }
675
676    #[test]
677    fn format_document_with_style_empty_citations() {
678        let style = make_test_style();
679        let refs = make_test_bibliography();
680        let request = FormatDocumentRequest {
681            style: StyleInput::Yaml("dummy".to_string()),
682            style_overrides: None,
683            locale: None,
684            output_format: OutputFormatKind::Plain,
685            refs,
686            citations: vec![],
687            bibliography_blocks: Vec::new(),
688            document_options: None,
689            nocite: vec![],
690        };
691
692        let result = format_document_with_style(style, request);
693        assert!(result.is_ok());
694        let res = result.unwrap();
695        assert_eq!(res.formatted_citations.len(), 0);
696    }
697
698    #[test]
699    fn format_document_html_bibliography_entries_preserve_inline_markup() {
700        let mut style = make_test_style();
701        style.bibliography = Some(BibliographySpec {
702            template: Some(vec![TemplateComponent::Title(TemplateTitle {
703                title: TitleType::Primary,
704                ..Default::default()
705            })]),
706            ..Default::default()
707        });
708
709        let request = FormatDocumentRequest {
710            style: StyleInput::Yaml("dummy".to_string()),
711            style_overrides: None,
712            locale: None,
713            output_format: OutputFormatKind::Html,
714            refs: make_markup_bibliography(),
715            citations: vec![],
716            bibliography_blocks: Vec::new(),
717            document_options: None,
718            // Use nocite to include art1 in the bibliography without an in-text citation;
719            // the test is validating bibliography HTML rendering, not citation rendering.
720            nocite: vec!["art1".to_string()],
721        };
722
723        let result = format_document_with_style(style, request).expect("should render");
724
725        assert_eq!(
726            result.bibliography.entries[0].text, result.bibliography.content,
727            "single-entry bibliography should mirror the full bibliography payload"
728        );
729        assert!(
730            result.bibliography.entries[0].text.contains(
731                "<span class=\"citum-title\"><em>Homo sapiens</em> and <b>modern</b> world</span>"
732            ),
733            "per-entry HTML should preserve inline markup for Djot-bearing titles"
734        );
735    }
736
737    #[test]
738    fn format_document_missing_ref_warning() {
739        let style = make_test_style();
740        let refs = make_test_bibliography();
741
742        let citation_occ = CitationOccurrence {
743            id: "cite1".to_string(),
744            items: vec![CitationOccurrenceItem {
745                id: "unknown_ref".to_string(),
746                locator: None,
747                prefix: None,
748                suffix: None,
749                integral_name_state: None,
750                org_abbreviation_state: None,
751            }],
752            mode: None,
753            note_number: None,
754            suppress_author: None,
755            grouped: None,
756            prefix: None,
757            suffix: None,
758            sentence_start: None,
759        };
760
761        let request = FormatDocumentRequest {
762            style: StyleInput::Yaml("dummy".to_string()),
763            style_overrides: None,
764            locale: None,
765            output_format: OutputFormatKind::Plain,
766            refs,
767            citations: vec![citation_occ],
768            bibliography_blocks: Vec::new(),
769            document_options: None,
770            nocite: vec![],
771        };
772
773        let result = format_document_with_style(style, request);
774        assert!(result.is_ok());
775        let res = result.unwrap();
776        assert!(res.warnings.iter().any(|w| w.code == "missing_ref"));
777    }
778
779    #[test]
780    fn format_document_unknown_reference_class_warning() {
781        let style = make_test_style();
782        let mut refs = Bibliography::new();
783        let unknown_ref: InputReference = serde_json::from_str(
784            r#"{
785                "class": "dance-performance",
786                "id": "pina2011",
787                "title": "Pina",
788                "issued": "2011",
789                "venue": "Berlin"
790            }"#,
791        )
792        .expect("unknown class should parse through the compatibility path");
793        refs.insert("pina2011".to_string(), unknown_ref);
794
795        let citation_occ = CitationOccurrence {
796            id: "cite1".to_string(),
797            items: vec![CitationOccurrenceItem {
798                id: "pina2011".to_string(),
799                locator: None,
800                prefix: None,
801                suffix: None,
802                integral_name_state: None,
803                org_abbreviation_state: None,
804            }],
805            mode: None,
806            note_number: None,
807            suppress_author: None,
808            grouped: None,
809            prefix: None,
810            suffix: None,
811            sentence_start: None,
812        };
813
814        let request = FormatDocumentRequest {
815            style: StyleInput::Yaml("dummy".to_string()),
816            style_overrides: None,
817            locale: None,
818            output_format: OutputFormatKind::Plain,
819            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
820            citations: vec![citation_occ],
821            bibliography_blocks: Vec::new(),
822            document_options: None,
823            nocite: vec![],
824        };
825
826        let result = format_document_with_style(style, request).unwrap();
827        let warning = result
828            .warnings
829            .iter()
830            .find(|w| w.code == "unknown_reference_class")
831            .expect("unknown class warning should be emitted");
832        assert_eq!(warning.ref_id.as_deref(), Some("pina2011"));
833        assert!(warning.message.contains("dance-performance"));
834    }
835
836    #[test]
837    fn format_document_yaml_style_input() {
838        let style = make_test_style();
839        let yaml_style = serde_yaml::to_string(&style).expect("serialize test style");
840
841        let mut refs = Bibliography::new();
842        refs.insert(
843            "test2024".to_string(),
844            InputReference::Monograph(Box::new(Monograph {
845                id: Some("test2024".into()),
846                r#type: MonographType::Book,
847                title: Some(Title::Single("Test Work".to_string())),
848                issued: EdtfString("2024".to_string()),
849                ..Default::default()
850            })),
851        );
852
853        let citation_occ = CitationOccurrence {
854            id: "c1".to_string(),
855            items: vec![CitationOccurrenceItem {
856                id: "test2024".to_string(),
857                locator: None,
858                prefix: None,
859                suffix: None,
860                integral_name_state: None,
861                org_abbreviation_state: None,
862            }],
863            mode: None,
864            note_number: None,
865            suppress_author: None,
866            grouped: None,
867            prefix: None,
868            suffix: None,
869            sentence_start: None,
870        };
871
872        let request = FormatDocumentRequest {
873            style: StyleInput::Yaml(yaml_style),
874            style_overrides: None,
875            locale: None,
876            output_format: OutputFormatKind::Plain,
877            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
878            citations: vec![citation_occ],
879            bibliography_blocks: Vec::new(),
880            document_options: None,
881            nocite: vec![],
882        };
883
884        let result = format_document(request);
885        assert!(result.is_ok());
886        let res = result.unwrap();
887        assert_eq!(res.formatted_citations.len(), 1);
888        assert!(!res.formatted_citations[0].text.is_empty());
889    }
890
891    #[test]
892    fn format_document_uri_input_unresolved() {
893        let request = FormatDocumentRequest {
894            style: StyleInput::Uri("https://example.com/style.yaml".to_string()),
895            style_overrides: None,
896            locale: None,
897            output_format: OutputFormatKind::Plain,
898            refs: RefsInput::Json(serde_json::Value::Object(Default::default())),
899            citations: vec![],
900            bibliography_blocks: Vec::new(),
901            document_options: None,
902            nocite: vec![],
903        };
904
905        let result = format_document(request);
906        match result {
907            Err(FormatDocumentError::UnresolvedInput(_)) => {
908                // Expected
909            }
910            _ => panic!("Expected UnresolvedInput error"),
911        }
912    }
913
914    /// A minimal resolver that returns a fixed style for any ID.
915    struct MockResolver(Style);
916
917    impl citum_resolver_api::StyleResolver for MockResolver {
918        type Style = Style;
919        type Locale = citum_schema::locale::Locale;
920
921        fn resolve_style(&self, _uri: &str) -> Result<Style, citum_schema::ResolverError> {
922            Ok(self.0.clone())
923        }
924
925        fn resolve_locale(
926            &self,
927            id: &str,
928        ) -> Result<citum_schema::locale::Locale, citum_schema::ResolverError> {
929            Err(citum_schema::ResolverError::LocaleNotFound(
930                std::borrow::Cow::Owned(id.to_string()),
931            ))
932        }
933    }
934
935    #[test]
936    fn format_document_with_resolver_injects_style_for_id_input() {
937        let style = make_test_style();
938        let resolver = MockResolver(style);
939        let refs = make_test_bibliography();
940
941        let citation_occ = CitationOccurrence {
942            id: "c1".to_string(),
943            items: vec![CitationOccurrenceItem {
944                id: "smith2020".to_string(),
945                locator: None,
946                prefix: None,
947                suffix: None,
948                integral_name_state: None,
949                org_abbreviation_state: None,
950            }],
951            mode: None,
952            note_number: None,
953            suppress_author: None,
954            grouped: None,
955            prefix: None,
956            suffix: None,
957            sentence_start: None,
958        };
959
960        let request = FormatDocumentRequest {
961            style: StyleInput::Id("any-id".to_string()),
962            style_overrides: None,
963            locale: None,
964            output_format: OutputFormatKind::Plain,
965            refs,
966            citations: vec![citation_occ],
967            bibliography_blocks: Vec::new(),
968            document_options: None,
969            nocite: vec![],
970        };
971
972        // Without a resolver, the same Id input must be rejected.
973        match format_document(request.clone()) {
974            Err(FormatDocumentError::UnresolvedInput(_)) => {}
975            other => panic!("expected UnresolvedInput without resolver, got: {other:?}"),
976        }
977
978        // With the injected resolver it must succeed.
979        let result = format_document_with_resolver(request, &resolver);
980        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
981        let res = result.unwrap();
982        assert_eq!(res.formatted_citations.len(), 1);
983        assert!(
984            !res.formatted_citations[0].text.is_empty(),
985            "formatted citation text should not be empty"
986        );
987    }
988
989    /// Build an author-date style whose citation template renders contributor short form.
990    fn make_two_author_style() -> Style {
991        Style {
992            info: StyleInfo {
993                title: Some("Override Test Style".to_string()),
994                id: Some("override-test".into()),
995                ..Default::default()
996            },
997            options: Some(Config {
998                processing: Some(Processing::AuthorDate),
999                // Explicitly set `and: text` so the override to `symbol` is observable
1000                // in rendered output without relying on any default connector.
1001                contributors: Some(ContributorConfig {
1002                    and: Some(AndOptions::Text),
1003                    ..Default::default()
1004                }),
1005                ..Default::default()
1006            }),
1007            citation: Some(CitationSpec {
1008                template: Some(vec![
1009                    TemplateComponent::Contributor(TemplateContributor {
1010                        contributor: ContributorRole::Author.into(),
1011                        form: ContributorForm::Short,
1012                        rendering: Rendering::default(),
1013                        ..Default::default()
1014                    }),
1015                    TemplateComponent::Date(TemplateDate {
1016                        date: TemplateDateVariable::Issued,
1017                        form: DateForm::Year,
1018                        rendering: Rendering {
1019                            prefix: Some(", ".to_string()),
1020                            ..Default::default()
1021                        },
1022                        ..Default::default()
1023                    }),
1024                ]),
1025                wrap: Some(WrapPunctuation::Parentheses.into()),
1026                ..Default::default()
1027            }),
1028            ..Default::default()
1029        }
1030    }
1031
1032    /// Build a refs input with a two-author book so the "and" connector is exercised.
1033    ///
1034    /// Uses inline YAML (the reliably tested deserialization path) rather than
1035    /// round-tripping through `serde_json::to_value` which may not preserve the
1036    /// contributor tagged-enum layout the engine expects.
1037    fn make_two_author_refs() -> RefsInput {
1038        RefsInput::Yaml(
1039            r#"duo2024:
1040  class: monograph
1041  id: duo2024
1042  type: book
1043  title: Duo Work
1044  issued: "2024"
1045  author:
1046    - family: Smith
1047      given: Alice
1048    - family: Jones
1049      given: Bob
1050"#
1051            .to_string(),
1052        )
1053    }
1054
1055    /// Helper: produce a single-item citation occurrence for a given ref id.
1056    fn cite(ref_id: &str) -> CitationOccurrence {
1057        CitationOccurrence {
1058            id: "c1".to_string(),
1059            items: vec![CitationOccurrenceItem {
1060                id: ref_id.to_string(),
1061                locator: None,
1062                prefix: None,
1063                suffix: None,
1064                integral_name_state: None,
1065                org_abbreviation_state: None,
1066            }],
1067            mode: None,
1068            note_number: None,
1069            suppress_author: None,
1070            grouped: None,
1071            prefix: None,
1072            suffix: None,
1073            sentence_start: None,
1074        }
1075    }
1076
1077    #[test]
1078    fn style_overrides_and_symbol_changes_rendered_output() {
1079        let base_style = make_two_author_style();
1080        let refs = make_two_author_refs();
1081
1082        // given: base style produces a citation containing "and"
1083        let request_base = FormatDocumentRequest {
1084            style: StyleInput::Yaml("dummy".to_string()),
1085            style_overrides: None,
1086            locale: None,
1087            output_format: OutputFormatKind::Plain,
1088            refs: refs.clone(),
1089            citations: vec![cite("duo2024")],
1090            bibliography_blocks: Vec::new(),
1091            document_options: None,
1092            nocite: vec![],
1093        };
1094        let result_base = format_document_with_style(base_style.clone(), request_base).unwrap();
1095        let text_base = &result_base.formatted_citations[0].text;
1096        assert!(
1097            text_base.contains("and"),
1098            "base style should use text 'and' connector, got: {text_base:?}"
1099        );
1100
1101        // when: style_overrides switches connector to symbol "&"
1102        let request_override = FormatDocumentRequest {
1103            style: StyleInput::Yaml("dummy".to_string()),
1104            style_overrides: Some("options:\n  contributors:\n    and: symbol\n".to_string()),
1105            locale: None,
1106            output_format: OutputFormatKind::Plain,
1107            refs,
1108            citations: vec![cite("duo2024")],
1109            bibliography_blocks: Vec::new(),
1110            document_options: None,
1111            nocite: vec![],
1112        };
1113        let result_override =
1114            format_document_with_style(base_style.clone(), request_override).unwrap();
1115        let text_override = &result_override.formatted_citations[0].text;
1116        assert!(
1117            text_override.contains('&'),
1118            "overridden style should use '&' connector, got: {text_override:?}"
1119        );
1120
1121        // then: base style struct is untouched — still has Text, not Symbol
1122        let base_and = base_style
1123            .options
1124            .as_ref()
1125            .and_then(|o| o.contributors.as_ref())
1126            .and_then(|c| c.and.as_ref());
1127        assert!(
1128            matches!(base_and, Some(&AndOptions::Text)),
1129            "base style must not be mutated; expected And::Text, got: {base_and:?}"
1130        );
1131    }
1132
1133    #[test]
1134    fn style_overrides_invalid_yaml_returns_parse_error() {
1135        let style = make_test_style();
1136        let refs = make_test_bibliography();
1137
1138        let request = FormatDocumentRequest {
1139            style: StyleInput::Yaml("dummy".to_string()),
1140            style_overrides: Some("{ unclosed yaml: [".to_string()),
1141            locale: None,
1142            output_format: OutputFormatKind::Plain,
1143            refs,
1144            citations: vec![],
1145            bibliography_blocks: Vec::new(),
1146            document_options: None,
1147            nocite: vec![],
1148        };
1149
1150        match format_document_with_style(style, request) {
1151            Err(FormatDocumentError::StyleParse(msg)) => {
1152                assert!(
1153                    msg.contains("style_overrides"),
1154                    "error message should mention style_overrides, got: {msg}"
1155                );
1156            }
1157            other => panic!("expected StyleParse error, got: {other:?}"),
1158        }
1159    }
1160
1161    #[test]
1162    fn apply_style_overrides_merges_option_field() {
1163        let mut style = make_test_style();
1164        apply_style_overrides(&mut style, "options:\n  contributors:\n    and: symbol\n")
1165            .expect("apply_style_overrides should succeed");
1166
1167        let and_option = style
1168            .options
1169            .as_ref()
1170            .and_then(|o| o.contributors.as_ref())
1171            .and_then(|c| c.and.as_ref());
1172        assert!(
1173            matches!(and_option, Some(&AndOptions::Symbol)),
1174            "expected And::Symbol after override, got: {and_option:?}"
1175        );
1176    }
1177
1178    // --- integral_name_memory wiring ---
1179
1180    /// Build a style that has integral-name memory configured with scope=Document,
1181    /// contexts=BodyAndNotes, subsequent_form=Short, and an integral sub-template
1182    /// that renders the author in Long (given + family) form.
1183    fn make_integral_name_style() -> Style {
1184        use citum_schema::options::{
1185            IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1186        };
1187        Style {
1188            info: StyleInfo {
1189                title: Some("Integral Name Memory Test".to_string()),
1190                id: Some("integral-name-memory-test".into()),
1191                ..Default::default()
1192            },
1193            options: Some(Config {
1194                processing: Some(Processing::AuthorDate),
1195                integral_name_memory: Some(IntegralNameMemoryConfig {
1196                    scope: Some(IntegralNameScope::Document),
1197                    contexts: Some(IntegralNameContexts::BodyAndNotes),
1198                    subsequent_form: Some(SubsequentNameForm::Short),
1199                    ..Default::default()
1200                }),
1201                ..Default::default()
1202            }),
1203            citation: Some(CitationSpec {
1204                integral: Some(Box::new(CitationSpec {
1205                    template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1206                        contributor: ContributorRole::Author.into(),
1207                        form: ContributorForm::Long,
1208                        rendering: Rendering::default(),
1209                        ..Default::default()
1210                    })]),
1211                    ..Default::default()
1212                })),
1213                template: Some(vec![
1214                    TemplateComponent::Contributor(TemplateContributor {
1215                        contributor: ContributorRole::Author.into(),
1216                        form: ContributorForm::Short,
1217                        rendering: Rendering::default(),
1218                        ..Default::default()
1219                    }),
1220                    TemplateComponent::Date(TemplateDate {
1221                        date: TemplateDateVariable::Issued,
1222                        form: DateForm::Year,
1223                        rendering: Rendering::default(),
1224                        ..Default::default()
1225                    }),
1226                ]),
1227                wrap: Some(WrapPunctuation::Parentheses.into()),
1228                ..Default::default()
1229            }),
1230            ..Default::default()
1231        }
1232    }
1233
1234    fn make_smith_refs() -> RefsInput {
1235        RefsInput::Yaml(
1236            r#"smith2020:
1237  class: monograph
1238  id: smith2020
1239  type: book
1240  title: Smith Book
1241  issued: "2020"
1242  author:
1243    - family: Smith
1244      given: John
1245"#
1246            .to_string(),
1247        )
1248    }
1249
1250    fn make_integral_occ(id: &str, ref_id: &str) -> CitationOccurrence {
1251        CitationOccurrence {
1252            id: id.to_string(),
1253            items: vec![CitationOccurrenceItem {
1254                id: ref_id.to_string(),
1255                locator: None,
1256                prefix: None,
1257                suffix: None,
1258                integral_name_state: None,
1259                org_abbreviation_state: None,
1260            }],
1261            mode: Some(citum_schema::data::citation::CitationMode::Integral),
1262            note_number: None,
1263            suppress_author: None,
1264            grouped: None,
1265            prefix: None,
1266            suffix: None,
1267            sentence_start: None,
1268        }
1269    }
1270
1271    #[test]
1272    fn document_options_integral_name_memory_first_full_then_short() {
1273        use crate::processor::document::DocumentIntegralNameOverride;
1274
1275        let style = make_integral_name_style();
1276        let refs = make_smith_refs();
1277
1278        let request = FormatDocumentRequest {
1279            style: StyleInput::Yaml("dummy".to_string()),
1280            style_overrides: None,
1281            locale: None,
1282            output_format: OutputFormatKind::Plain,
1283            refs,
1284            citations: vec![
1285                make_integral_occ("c1", "smith2020"),
1286                make_integral_occ("c2", "smith2020"),
1287            ],
1288            bibliography_blocks: Vec::new(),
1289            document_options: Some(DocumentOptions {
1290                integral_name_memory: Some(DocumentIntegralNameOverride {
1291                    enabled: Some(true),
1292                    ..Default::default()
1293                }),
1294                ..Default::default()
1295            }),
1296            nocite: vec![],
1297        };
1298
1299        let result = format_document_with_style(style, request).expect("should render");
1300
1301        assert!(
1302            !result
1303                .warnings
1304                .iter()
1305                .any(|w| w.code == "integral_name_memory_not_applied"),
1306            "stale warning must not appear: {:?}",
1307            result.warnings
1308        );
1309        assert_eq!(
1310            result.formatted_citations[0].text, "John Smith",
1311            "first integral cite should render full name form"
1312        );
1313        assert_eq!(
1314            result.formatted_citations[1].text, "Smith",
1315            "second integral cite of same author should render short form"
1316        );
1317    }
1318
1319    #[test]
1320    fn document_options_integral_name_memory_disabled_keeps_full_form() {
1321        use crate::processor::document::DocumentIntegralNameOverride;
1322
1323        let style = make_integral_name_style();
1324        let refs = make_smith_refs();
1325
1326        let request = FormatDocumentRequest {
1327            style: StyleInput::Yaml("dummy".to_string()),
1328            style_overrides: None,
1329            locale: None,
1330            output_format: OutputFormatKind::Plain,
1331            refs,
1332            citations: vec![
1333                make_integral_occ("c1", "smith2020"),
1334                make_integral_occ("c2", "smith2020"),
1335            ],
1336            bibliography_blocks: Vec::new(),
1337            document_options: Some(DocumentOptions {
1338                integral_name_memory: Some(DocumentIntegralNameOverride {
1339                    enabled: Some(false),
1340                    ..Default::default()
1341                }),
1342                ..Default::default()
1343            }),
1344            nocite: vec![],
1345        };
1346
1347        let result = format_document_with_style(style, request).expect("should render");
1348
1349        // With memory disabled both occurrences should render the natural integral
1350        // template form (Long = "John Smith") without any subsequent rewrite.
1351        assert_eq!(
1352            result.formatted_citations[0].text, "John Smith",
1353            "first integral cite: {}",
1354            result.formatted_citations[0].text
1355        );
1356        assert_eq!(
1357            result.formatted_citations[1].text, "John Smith",
1358            "second integral cite should also be full when memory is disabled"
1359        );
1360    }
1361
1362    #[test]
1363    fn style_native_integral_name_memory_applied_without_document_override() {
1364        // Style has integral_name_memory in its own options; no document_options
1365        // override is supplied. The flat API must still annotate First/Subsequent.
1366        let style = make_integral_name_style();
1367        let refs = make_smith_refs();
1368
1369        let request = FormatDocumentRequest {
1370            style: StyleInput::Yaml("dummy".to_string()),
1371            style_overrides: None,
1372            locale: None,
1373            output_format: OutputFormatKind::Plain,
1374            refs,
1375            citations: vec![
1376                make_integral_occ("c1", "smith2020"),
1377                make_integral_occ("c2", "smith2020"),
1378            ],
1379            bibliography_blocks: Vec::new(),
1380            document_options: None,
1381            nocite: vec![],
1382        };
1383
1384        let result = format_document_with_style(style, request).expect("should render");
1385
1386        assert_eq!(
1387            result.formatted_citations[0].text, "John Smith",
1388            "first integral cite should render full name form"
1389        );
1390        assert_eq!(
1391            result.formatted_citations[1].text, "Smith",
1392            "second integral cite should render short form from style-native config"
1393        );
1394    }
1395
1396    #[test]
1397    fn format_document_bibliography_blocks_ordered_with_dedup() {
1398        use citum_schema::grouping::CitedStatus;
1399        use citum_schema::grouping::{BibliographyGroup, GroupSelector};
1400
1401        let mut style = make_test_style();
1402        style.bibliography = Some(BibliographySpec {
1403            template: Some(vec![TemplateComponent::Title(TemplateTitle {
1404                title: TitleType::Primary,
1405                ..Default::default()
1406            })]),
1407            ..Default::default()
1408        });
1409        let mut refs = Bibliography::new();
1410        refs.insert(
1411            "smith2020".to_string(),
1412            InputReference::Monograph(Box::new(Monograph {
1413                id: Some("smith2020".into()),
1414                r#type: MonographType::Book,
1415                title: Some(Title::Single("Sample Work".to_string())),
1416                issued: EdtfString("2020".to_string()),
1417                ..Default::default()
1418            })),
1419        );
1420        refs.insert(
1421            "jones2019".to_string(),
1422            InputReference::Monograph(Box::new(Monograph {
1423                id: Some("jones2019".into()),
1424                r#type: MonographType::Book,
1425                title: Some(Title::Single("Another Work".to_string())),
1426                issued: EdtfString("2019".to_string()),
1427                ..Default::default()
1428            })),
1429        );
1430
1431        let make_block = |id: &str| crate::BibliographyBlockRequest {
1432            id: id.to_string(),
1433            group: BibliographyGroup {
1434                id: id.to_string(),
1435                selector: GroupSelector {
1436                    cited: Some(CitedStatus::Any),
1437                    ..Default::default()
1438                },
1439                ..Default::default()
1440            },
1441        };
1442
1443        let request = FormatDocumentRequest {
1444            style: StyleInput::Yaml("dummy".to_string()),
1445            style_overrides: None,
1446            locale: None,
1447            output_format: OutputFormatKind::Plain,
1448            refs: RefsInput::Json(serde_json::to_value(refs).unwrap()),
1449            citations: vec![],
1450            bibliography_blocks: vec![make_block("block-a"), make_block("block-b")],
1451            document_options: None,
1452            nocite: vec![],
1453        };
1454
1455        let result = format_document_with_style(style, request).expect("should render");
1456
1457        assert_eq!(result.bibliography_blocks.len(), 2, "both blocks returned");
1458        assert_eq!(result.bibliography_blocks[0].id, "block-a");
1459        assert_eq!(result.bibliography_blocks[1].id, "block-b");
1460
1461        let block_a_count = result.bibliography_blocks[0].entries.len();
1462        let block_b_count = result.bibliography_blocks[1].entries.len();
1463
1464        assert_eq!(block_a_count, 2, "block-a captures both refs");
1465        assert_eq!(
1466            block_b_count, 0,
1467            "block-b is empty: dedup set prevents re-assignment from block-a"
1468        );
1469    }
1470
1471    // --- nocite tests ---
1472
1473    /// A ref listed only in `nocite` must appear in the bibliography but produce
1474    /// no `formatted_citations` entry (standard citeproc nocite semantics).
1475    #[test]
1476    fn nocite_ref_in_bibliography_not_in_formatted_citations() {
1477        let mut style = make_test_style();
1478        // A bibliography template is required for entries to be produced.
1479        style.bibliography = Some(BibliographySpec {
1480            template: Some(vec![TemplateComponent::Title(TemplateTitle {
1481                title: TitleType::Primary,
1482                ..Default::default()
1483            })]),
1484            ..Default::default()
1485        });
1486        let refs = make_test_bibliography(); // contains "smith2020"
1487
1488        let request = FormatDocumentRequest {
1489            style: StyleInput::Yaml("dummy".to_string()),
1490            style_overrides: None,
1491            locale: None,
1492            output_format: OutputFormatKind::Plain,
1493            refs,
1494            citations: vec![],
1495            bibliography_blocks: Vec::new(),
1496            document_options: None,
1497            nocite: vec!["smith2020".to_string()],
1498        };
1499
1500        let result = format_document_with_style(style, request).expect("should render");
1501
1502        assert_eq!(
1503            result.formatted_citations.len(),
1504            0,
1505            "nocite refs must not produce a formatted citation"
1506        );
1507        assert_eq!(
1508            result.bibliography.entries.len(),
1509            1,
1510            "nocite ref must appear in bibliography entries"
1511        );
1512        assert_eq!(
1513            result.bibliography.entries[0].id, "smith2020",
1514            "bibliography entry id should match nocite ref"
1515        );
1516        assert!(
1517            !result.bibliography.content.is_empty(),
1518            "bibliography content must be non-empty for nocite ref"
1519        );
1520        assert!(
1521            result.warnings.is_empty(),
1522            "no warnings expected: {:?}",
1523            result.warnings
1524        );
1525    }
1526
1527    /// An ID listed in `nocite` that is absent from `refs` must emit a
1528    /// `nocite_missing_ref` warning and not appear in the bibliography.
1529    #[test]
1530    fn nocite_missing_ref_emits_warning() {
1531        let style = make_test_style();
1532        let refs = make_test_bibliography();
1533
1534        let request = FormatDocumentRequest {
1535            style: StyleInput::Yaml("dummy".to_string()),
1536            style_overrides: None,
1537            locale: None,
1538            output_format: OutputFormatKind::Plain,
1539            refs,
1540            citations: vec![],
1541            bibliography_blocks: Vec::new(),
1542            document_options: None,
1543            nocite: vec!["does_not_exist".to_string()],
1544        };
1545
1546        let result = format_document_with_style(style, request).expect("should render");
1547
1548        assert_eq!(
1549            result.bibliography.entries.len(),
1550            0,
1551            "absent nocite ref must not produce a bibliography entry"
1552        );
1553        let warning = result
1554            .warnings
1555            .iter()
1556            .find(|w| w.code == "nocite_missing_ref")
1557            .expect("nocite_missing_ref warning should be emitted");
1558        assert_eq!(
1559            warning.ref_id.as_deref(),
1560            Some("does_not_exist"),
1561            "warning ref_id should name the absent nocite key"
1562        );
1563    }
1564
1565    /// A nocite ref must sort alongside the cited ref when both are present
1566    /// (i.e., citation status does not affect bibliography sort order).
1567    #[test]
1568    fn nocite_ref_sorts_alongside_cited_ref() {
1569        let mut style = make_test_style();
1570        style.bibliography = Some(BibliographySpec {
1571            template: Some(vec![TemplateComponent::Title(TemplateTitle {
1572                title: TitleType::Primary,
1573                ..Default::default()
1574            })]),
1575            ..Default::default()
1576        });
1577
1578        let citation_occ = CitationOccurrence {
1579            id: "c1".to_string(),
1580            items: vec![CitationOccurrenceItem {
1581                id: "duo2024".to_string(),
1582                locator: None,
1583                prefix: None,
1584                suffix: None,
1585                integral_name_state: None,
1586                org_abbreviation_state: None,
1587            }],
1588            mode: None,
1589            note_number: None,
1590            suppress_author: None,
1591            grouped: None,
1592            prefix: None,
1593            suffix: None,
1594            sentence_start: None,
1595        };
1596
1597        // Two refs: duo2024 (cited via citation_occ) + smith2020 (nocite-only).
1598        let combined_refs = RefsInput::Yaml(
1599            r#"duo2024:
1600  class: monograph
1601  id: duo2024
1602  type: book
1603  title: Duo Work
1604  issued: "2024"
1605  author:
1606    - family: Smith
1607      given: Alice
1608    - family: Jones
1609      given: Bob
1610smith2020:
1611  class: monograph
1612  id: smith2020
1613  type: book
1614  title: Smith Work
1615  issued: "2020"
1616  author:
1617    - family: Smith
1618      given: Alex
1619"#
1620            .to_string(),
1621        );
1622
1623        let request = FormatDocumentRequest {
1624            style: StyleInput::Yaml("dummy".to_string()),
1625            style_overrides: None,
1626            locale: None,
1627            output_format: OutputFormatKind::Plain,
1628            refs: combined_refs,
1629            citations: vec![citation_occ],
1630            bibliography_blocks: Vec::new(),
1631            document_options: None,
1632            nocite: vec!["smith2020".to_string()],
1633        };
1634
1635        let result = format_document_with_style(style, request).expect("should render");
1636
1637        assert_eq!(result.formatted_citations.len(), 1, "one in-text citation");
1638        assert_eq!(
1639            result.bibliography.entries.len(),
1640            2,
1641            "both cited and nocite refs must appear in the bibliography"
1642        );
1643        let ids: Vec<&str> = result
1644            .bibliography
1645            .entries
1646            .iter()
1647            .map(|e| e.id.as_str())
1648            .collect();
1649        assert!(
1650            ids.contains(&"duo2024"),
1651            "cited ref must be in bibliography: {ids:?}"
1652        );
1653        assert!(
1654            ids.contains(&"smith2020"),
1655            "nocite ref must be in bibliography: {ids:?}"
1656        );
1657    }
1658
1659    fn apa_style_path() -> String {
1660        use std::path::PathBuf;
1661        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1662            .parent()
1663            .unwrap()
1664            .parent()
1665            .unwrap()
1666            .join("styles/embedded/apa-7th.yaml")
1667            .to_str()
1668            .unwrap()
1669            .to_string()
1670    }
1671
1672    fn chicago_notes_path() -> String {
1673        use std::path::PathBuf;
1674        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1675            .parent()
1676            .unwrap()
1677            .parent()
1678            .unwrap()
1679            .join("styles/embedded/chicago-notes-18th.yaml")
1680            .to_str()
1681            .unwrap()
1682            .to_string()
1683    }
1684
1685    fn make_citation_occ(id: &str, ref_id: &str, mode: Option<CitationMode>) -> CitationOccurrence {
1686        CitationOccurrence {
1687            id: id.to_string(),
1688            items: vec![CitationOccurrenceItem {
1689                id: ref_id.to_string(),
1690                locator: None,
1691                prefix: None,
1692                suffix: None,
1693                integral_name_state: None,
1694                org_abbreviation_state: None,
1695            }],
1696            mode,
1697            note_number: None,
1698            suppress_author: None,
1699            grouped: None,
1700            prefix: None,
1701            suffix: None,
1702            sentence_start: None,
1703        }
1704    }
1705
1706    #[test]
1707    fn format_document_author_date_mixed_citation_modes_order_preserved() {
1708        let refs = RefsInput::Json(serde_json::json!({
1709            "smith2020": {
1710                "id": "smith2020",
1711                "class": "monograph",
1712                "type": "book",
1713                "title": "Sample Work",
1714                "author": [{"family": "Smith", "given": "John"}],
1715                "issued": "2020"
1716            }
1717        }));
1718
1719        let request = FormatDocumentRequest {
1720            style: StyleInput::Path(apa_style_path()),
1721            style_overrides: None,
1722            locale: None,
1723            output_format: OutputFormatKind::Plain,
1724            refs,
1725            citations: vec![
1726                make_citation_occ("cite-integral", "smith2020", Some(CitationMode::Integral)),
1727                make_citation_occ(
1728                    "cite-non-integral",
1729                    "smith2020",
1730                    Some(CitationMode::NonIntegral),
1731                ),
1732            ],
1733            bibliography_blocks: Vec::new(),
1734            document_options: None,
1735            nocite: vec![],
1736        };
1737
1738        let result = format_document(request).expect("format_document should succeed");
1739
1740        assert_eq!(
1741            result.formatted_citations.len(),
1742            2,
1743            "both citations should be returned"
1744        );
1745        assert_eq!(
1746            result.formatted_citations[0].id, "cite-integral",
1747            "document order must be preserved"
1748        );
1749        assert_eq!(
1750            result.formatted_citations[1].id, "cite-non-integral",
1751            "document order must be preserved"
1752        );
1753
1754        let integral = &result.formatted_citations[0].text;
1755        let non_integral = &result.formatted_citations[1].text;
1756
1757        assert!(
1758            !integral.starts_with('('),
1759            "integral citation should place author name outside parentheses: {integral:?}"
1760        );
1761        assert!(
1762            integral.contains("Smith"),
1763            "integral citation should contain author name: {integral:?}"
1764        );
1765        assert!(
1766            non_integral.starts_with('('),
1767            "non-integral citation should be fully parenthetical: {non_integral:?}"
1768        );
1769    }
1770
1771    #[test]
1772    fn format_document_note_style_repeat_citations_produce_ibid() {
1773        let refs = RefsInput::Json(serde_json::json!({
1774            "smith1995": {
1775                "id": "smith1995",
1776                "class": "monograph",
1777                "type": "book",
1778                "title": "A Great Book",
1779                "author": [{"family": "Smith", "given": "John"}],
1780                "issued": "1995"
1781            }
1782        }));
1783
1784        let request = FormatDocumentRequest {
1785            style: StyleInput::Path(chicago_notes_path()),
1786            style_overrides: None,
1787            locale: None,
1788            output_format: OutputFormatKind::Plain,
1789            refs,
1790            citations: vec![
1791                make_citation_occ("cite-1", "smith1995", None),
1792                make_citation_occ("cite-2", "smith1995", None),
1793                make_citation_occ("cite-3", "smith1995", None),
1794            ],
1795            bibliography_blocks: Vec::new(),
1796            document_options: None,
1797            nocite: vec![],
1798        };
1799
1800        let result = format_document(request).expect("format_document should succeed");
1801
1802        assert_eq!(result.formatted_citations.len(), 3);
1803
1804        let first = &result.formatted_citations[0].text;
1805        let second = &result.formatted_citations[1].text;
1806        let third = &result.formatted_citations[2].text;
1807
1808        assert!(
1809            first.contains("Smith"),
1810            "first citation should render full form: {first:?}"
1811        );
1812        assert_eq!(
1813            second.as_str(),
1814            "Ibid.",
1815            "immediate repeat should render as ibid: {second:?}"
1816        );
1817        assert_eq!(
1818            third.as_str(),
1819            "Ibid.",
1820            "third repeat should also render as ibid: {third:?}"
1821        );
1822    }
1823
1824    #[test]
1825    fn format_document_annotations_appear_in_bibliography() {
1826        let refs = RefsInput::Json(serde_json::json!({
1827            "smith2020": {
1828                "id": "smith2020",
1829                "class": "monograph",
1830                "type": "book",
1831                "title": "Sample Work",
1832                "author": [{"family": "Smith", "given": "John"}],
1833                "issued": "2020"
1834            }
1835        }));
1836
1837        let mut annotations = HashMap::new();
1838        annotations.insert(
1839            "smith2020".to_string(),
1840            "Foundational work on the topic.".to_string(),
1841        );
1842
1843        let request = FormatDocumentRequest {
1844            style: StyleInput::Path(apa_style_path()),
1845            style_overrides: None,
1846            locale: None,
1847            output_format: OutputFormatKind::Plain,
1848            refs,
1849            citations: vec![make_citation_occ("cite-1", "smith2020", None)],
1850            bibliography_blocks: Vec::new(),
1851            document_options: Some(DocumentOptions {
1852                annotations: Some(annotations),
1853                ..Default::default()
1854            }),
1855            nocite: vec![],
1856        };
1857
1858        let result = format_document(request).expect("format_document should succeed");
1859
1860        assert!(
1861            result
1862                .bibliography
1863                .content
1864                .contains("Foundational work on the topic."),
1865            "annotation text should appear in bibliography output: {:?}",
1866            result.bibliography.content
1867        );
1868    }
1869}