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