Skip to main content

citum_engine/api/
document.rs

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