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