Skip to main content

citum_engine/api/
document.rs

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