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