Skip to main content

citum_engine/api/
session.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Stateful document session API for interactive adapters.
7
8use crate::render::djot::Djot;
9use crate::render::html::Html;
10use crate::render::latex::Latex;
11use crate::render::markdown::Markdown;
12use crate::render::plain::PlainText;
13use crate::render::typst::Typst;
14use citum_schema::Style;
15use citum_schema::options::Processing;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19use super::document::{format_bibliography, format_by_kind};
20use super::{
21    CitationOccurrence, CitationOccurrenceItem, DocumentOptions, FormatDocumentError,
22    FormattedBibliography, FormattedCitation, OutputFormatKind, RefsInput, StyleInput, Warning,
23    WarningLevel, bibliography_label_missing_separator_warnings, term_locale_fallback_warnings,
24    unknown_enum_warnings, unknown_reference_class_warnings, unknown_reference_field_warnings,
25};
26use crate::processor::Processor;
27use crate::reference::{Bibliography, Citation};
28
29/// Position context for inserting or moving a citation in a session.
30#[derive(Debug, Clone, Default, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub struct CitationInsertPosition {
33    /// Citation ID that should precede the inserted citation.
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub after_citation_id: Option<String>,
36    /// Citation ID that should follow the inserted citation.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub before_citation_id: Option<String>,
39}
40
41/// Result returned when a new interactive session is opened.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct OpenSessionResult {
44    /// Opaque session identifier used by transport adapters.
45    pub session_id: String,
46}
47
48/// Result returned by mutation methods.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct SessionMutationResult {
51    /// Monotonic session version after the mutation.
52    pub version: u64,
53    /// Complete set of citations whose rendered output changed.
54    pub affected_citations: Vec<FormattedCitation>,
55    /// Current bibliography after the mutation.
56    pub bibliography: FormattedBibliography,
57    /// True when numeric citation labels or note numbers shifted.
58    pub renumbering_occurred: bool,
59    /// Non-fatal diagnostics encountered during rendering.
60    pub warnings: Vec<Warning>,
61}
62
63/// Result returned by citation preview.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct PreviewCitationResult {
66    /// Rendered preview text.
67    pub preview: String,
68    /// Non-fatal diagnostics encountered during preview rendering.
69    pub warnings: Vec<Warning>,
70}
71
72/// Errors returned by the stateful session API.
73#[derive(thiserror::Error, Debug)]
74pub enum DocumentSessionError {
75    /// The requested citation does not exist in the session.
76    #[error("citation not found: {0}")]
77    CitationNotFound(String),
78    /// The requested insertion position is invalid.
79    #[error("invalid citation position: {0}")]
80    InvalidPosition(String),
81    /// Rendering failed while recomputing session output.
82    #[error(transparent)]
83    Format(#[from] FormatDocumentError),
84}
85
86/// Stateful facade over whole-document citation rendering.
87///
88/// The session caches its inputs in resolved form: the style is stored
89/// already resolved (see [`DocumentSession::new`]) and references are parsed
90/// once in [`DocumentSession::put_references`]. Each mutation still clones
91/// both into a fresh [`Processor`] (including disambiguation-hint
92/// calculation) and re-renders every citation plus the bibliography —
93/// incremental re-rendering is not yet implemented.
94#[derive(Debug, Clone)]
95pub struct DocumentSession {
96    style: Style,
97    locale: Option<String>,
98    output_format: OutputFormatKind,
99    document_options: Option<DocumentOptions>,
100    /// Resolved references, parsed once per `put_references` call.
101    bibliography_cache: Bibliography,
102    /// Bibliography-derived warnings, computed once per `put_references` call.
103    ref_warnings: Vec<Warning>,
104    citations: Vec<CitationOccurrence>,
105    /// Reference IDs registered for bibliography-only inclusion (nocite).
106    ///
107    /// IDs in this set appear in the bibliography alongside cited refs but
108    /// produce no `formatted_citations` entry (standard citeproc nocite).
109    nocite: Vec<String>,
110    version: u64,
111    formatted_citations: Vec<FormattedCitation>,
112    bibliography: Option<FormattedBibliography>,
113    warnings: Vec<Warning>,
114}
115
116impl DocumentSession {
117    /// Create a session with an already-resolved style.
118    pub fn new(
119        style: Style,
120        _style_input: StyleInput,
121        locale: Option<String>,
122        output_format: OutputFormatKind,
123        document_options: Option<DocumentOptions>,
124    ) -> Self {
125        Self {
126            style,
127            locale,
128            output_format,
129            document_options,
130            bibliography_cache: Bibliography::new(),
131            ref_warnings: Vec::new(),
132            citations: Vec::new(),
133            nocite: Vec::new(),
134            version: 0,
135            formatted_citations: Vec::new(),
136            bibliography: None,
137            warnings: Vec::new(),
138        }
139    }
140
141    /// Return the current session version.
142    pub fn version(&self) -> u64 {
143        self.version
144    }
145
146    /// Replace the full reference set used by this session.
147    ///
148    /// The input is parsed here, once, and the resolved bibliography (plus
149    /// its reference-level warnings) is cached for all subsequent mutations
150    /// and previews. Calling this again replaces the cache wholesale.
151    ///
152    /// # Errors
153    ///
154    /// Returns an error when the reference input cannot be parsed.
155    pub fn put_references(&mut self, refs: RefsInput) -> Result<(), DocumentSessionError> {
156        let bibliography = refs.resolve_local()?;
157        let mut ref_warnings = unknown_reference_class_warnings(&bibliography);
158        ref_warnings.extend(unknown_reference_field_warnings(&bibliography));
159        self.bibliography_cache = bibliography;
160        self.ref_warnings = ref_warnings;
161        Ok(())
162    }
163
164    /// Set the nocite list and re-render the session bibliography.
165    ///
166    /// Nocite IDs appear in the bibliography alongside cited refs but produce
167    /// no `formatted_citations` entry. IDs absent from the current reference
168    /// set emit a `nocite_missing_ref` warning and are silently dropped.
169    ///
170    /// # Errors
171    ///
172    /// Returns an error when re-rendering the session output fails.
173    pub fn set_nocite(
174        &mut self,
175        ids: Vec<String>,
176    ) -> Result<SessionMutationResult, DocumentSessionError> {
177        let old_citations = self.citations.clone();
178        let old_formatted = self.formatted_citations.clone();
179        self.nocite = ids;
180        self.commit_render(old_citations, old_formatted)
181    }
182
183    /// Replace the full ordered citation list.
184    ///
185    /// # Errors
186    ///
187    /// Returns an error when recomputing the formatted session output fails.
188    pub fn insert_citations_batch(
189        &mut self,
190        citations: Vec<CitationOccurrence>,
191    ) -> Result<SessionMutationResult, DocumentSessionError> {
192        let old_citations = self.citations.clone();
193        let old_formatted = self.formatted_citations.clone();
194        self.citations = citations;
195        self.commit_render(old_citations, old_formatted)
196    }
197
198    /// Insert a citation at the requested position.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error when the requested position is invalid or rendering fails.
203    pub fn insert_citation(
204        &mut self,
205        citation: CitationOccurrence,
206        position: Option<CitationInsertPosition>,
207    ) -> Result<SessionMutationResult, DocumentSessionError> {
208        let old_citations = self.citations.clone();
209        let old_formatted = self.formatted_citations.clone();
210        let index = self.resolve_insert_index(position.as_ref())?;
211        self.citations.insert(index, citation);
212        self.commit_render(old_citations, old_formatted)
213    }
214
215    /// Update an existing citation, optionally moving it to a new position.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error when the citation does not exist, the requested
220    /// position is invalid, or rendering fails.
221    pub fn update_citation(
222        &mut self,
223        citation_id: &str,
224        mut citation: CitationOccurrence,
225        position: Option<CitationInsertPosition>,
226    ) -> Result<SessionMutationResult, DocumentSessionError> {
227        let current_index = self
228            .citation_index(citation_id)
229            .ok_or_else(|| DocumentSessionError::CitationNotFound(citation_id.to_string()))?;
230        let old_citations = self.citations.clone();
231        let old_formatted = self.formatted_citations.clone();
232        citation.id = citation_id.to_string();
233        self.citations.remove(current_index);
234        let index = if let Some(position) = position.as_ref() {
235            self.resolve_insert_index(Some(position))?
236        } else {
237            current_index.min(self.citations.len())
238        };
239        self.citations.insert(index, citation);
240        self.commit_render(old_citations, old_formatted)
241    }
242
243    /// Delete a citation by ID.
244    ///
245    /// # Errors
246    ///
247    /// Returns an error when the citation does not exist or rendering fails.
248    pub fn delete_citation(
249        &mut self,
250        citation_id: &str,
251    ) -> Result<SessionMutationResult, DocumentSessionError> {
252        let index = self
253            .citation_index(citation_id)
254            .ok_or_else(|| DocumentSessionError::CitationNotFound(citation_id.to_string()))?;
255        let old_citations = self.citations.clone();
256        let old_formatted = self.formatted_citations.clone();
257        self.citations.remove(index);
258        self.commit_render(old_citations, old_formatted)
259    }
260
261    /// Render a citation preview without mutating session state.
262    ///
263    /// # Errors
264    ///
265    /// Returns an error when the requested preview position is invalid or
266    /// rendering fails.
267    pub fn preview_citation(
268        &self,
269        items: Vec<CitationOccurrenceItem>,
270        mode: Option<citum_schema::data::citation::CitationMode>,
271        position: Option<CitationInsertPosition>,
272    ) -> Result<PreviewCitationResult, DocumentSessionError> {
273        let mut citations = self.citations.clone();
274        let index = self.resolve_insert_index_in(&citations, position.as_ref())?;
275        let preview_id = "__citum_preview__".to_string();
276        citations.insert(
277            index,
278            CitationOccurrence {
279                id: preview_id.clone(),
280                items,
281                mode,
282                note_number: None,
283                suppress_author: None,
284                grouped: None,
285                prefix: None,
286                suffix: None,
287                sentence_start: None,
288            },
289        );
290        let rendered = self.render_citations(&citations)?;
291        let preview = rendered
292            .formatted_citations
293            .iter()
294            .find(|citation| citation.id == preview_id)
295            .map(|citation| citation.text.clone())
296            .unwrap_or_default();
297        Ok(PreviewCitationResult {
298            preview,
299            warnings: rendered.warnings,
300        })
301    }
302
303    /// Return the current formatted citations.
304    pub fn get_citations(&self) -> Vec<FormattedCitation> {
305        self.formatted_citations.clone()
306    }
307
308    /// Return the current bibliography, if a mutation has rendered one.
309    pub fn get_bibliography(&self) -> Option<FormattedBibliography> {
310        self.bibliography.clone()
311    }
312
313    fn commit_render(
314        &mut self,
315        old_citations: Vec<CitationOccurrence>,
316        old_formatted: Vec<FormattedCitation>,
317    ) -> Result<SessionMutationResult, DocumentSessionError> {
318        let rendered = self.render_citations(&self.citations)?;
319        let affected_citations =
320            diff_formatted_citations(&old_formatted, &rendered.formatted_citations);
321        let renumbering_occurred = renumbering_occurred(
322            &self.style,
323            &old_citations,
324            &self.citations,
325            &old_formatted,
326            &rendered.formatted_citations,
327        );
328        self.version += 1;
329        self.formatted_citations = rendered.formatted_citations;
330        self.bibliography = Some(rendered.bibliography.clone());
331        self.warnings = rendered.warnings.clone();
332        Ok(SessionMutationResult {
333            version: self.version,
334            affected_citations,
335            bibliography: rendered.bibliography,
336            renumbering_occurred,
337            warnings: rendered.warnings,
338        })
339    }
340
341    #[allow(
342        clippy::too_many_lines,
343        reason = "session rendering mirrors Tier 1 setup and format dispatch"
344    )]
345    fn render_citations(
346        &self,
347        citations: &[CitationOccurrence],
348    ) -> Result<SessionRenderResult, FormatDocumentError> {
349        let mut warnings = Vec::new();
350        if let Some(tag) = &self.locale
351            && !tag.is_empty()
352            && !tag.eq_ignore_ascii_case("en-us")
353        {
354            warnings.push(Warning {
355                level: WarningLevel::Warning,
356                code: "locale_fallback".to_string(),
357                citation_id: None,
358                ref_id: None,
359                message: format!(
360                    "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
361                ),
362            });
363        }
364
365        // References were resolved once in `put_references`; each render still
366        // clones the style and bibliography into a fresh processor.
367        let mut processor = Processor::new(self.style.clone(), self.bibliography_cache.clone());
368        warnings.extend(self.ref_warnings.iter().cloned());
369        warnings.extend(unknown_enum_warnings(&processor));
370        warnings.extend(term_locale_fallback_warnings(&processor));
371        warnings.extend(bibliography_label_missing_separator_warnings(&processor));
372
373        if let Some(opts) = &self.document_options {
374            // Rebuild the processor with the document-level integral-name override
375            // before applying scalar field mutations so those are not lost.
376            if let Some(new_proc) = processor
377                .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
378            {
379                processor = new_proc;
380            }
381            if let Some(show_semantics) = opts.show_semantics {
382                processor.show_semantics = show_semantics;
383            }
384            if let Some(inject_ast) = opts.inject_ast_indices {
385                processor.set_inject_ast_indices(inject_ast);
386            }
387            if let Some(abbr_map) = opts.abbreviation_map.clone() {
388                processor.abbreviation_map = Some(abbr_map);
389            }
390        }
391
392        let mut processor_citations: Vec<Citation> = Vec::new();
393        for occ in citations.iter().cloned() {
394            let mut citation: Citation = occ.into();
395            citation.items.retain(|item| {
396                if processor.bibliography.contains_key(&item.id) {
397                    true
398                } else {
399                    warnings.push(Warning {
400                        level: WarningLevel::Warning,
401                        code: "missing_ref".to_string(),
402                        citation_id: citation.id.clone(),
403                        ref_id: Some(item.id.clone()),
404                        message: format!("Reference '{}' not found in bibliography", item.id),
405                    });
406                    false
407                }
408            });
409            processor_citations.push(citation);
410        }
411
412        // Annotate integral-name First/Subsequent state from the processor's
413        // effective config (no document structure available; all citations share
414        // document scope). Safe no-op when no memory config is present.
415        processor.annotate_flat_integral_name_states(&mut processor_citations);
416
417        // Register nocite IDs: validate against bibliography, warn on missing, then
418        // add to cited_ids so they appear in bibliography entries but produce no
419        // citation text.
420        let nocite_ids: Vec<String> = self
421            .nocite
422            .iter()
423            .filter_map(|id| {
424                if processor.bibliography.contains_key(id) {
425                    Some(id.clone())
426                } else {
427                    warnings.push(Warning {
428                        level: WarningLevel::Warning,
429                        code: "nocite_missing_ref".to_string(),
430                        citation_id: None,
431                        ref_id: Some(id.clone()),
432                        message: format!("Nocite reference '{id}' not found in bibliography"),
433                    });
434                    None
435                }
436            })
437            .collect();
438
439        // One run threads registration across citations and nocite
440        // registration, then finalizes once for bibliography rendering.
441        let mut run = processor.begin_run();
442
443        let formatted_citations = match self.output_format {
444            OutputFormatKind::Plain => {
445                format_by_kind::<PlainText>(&processor, &processor_citations, &mut run)?
446            }
447            OutputFormatKind::Html => {
448                format_by_kind::<Html>(&processor, &processor_citations, &mut run)?
449            }
450            OutputFormatKind::Djot => {
451                format_by_kind::<Djot>(&processor, &processor_citations, &mut run)?
452            }
453            OutputFormatKind::Latex => {
454                format_by_kind::<Latex>(&processor, &processor_citations, &mut run)?
455            }
456            OutputFormatKind::Typst => {
457                format_by_kind::<Typst>(&processor, &processor_citations, &mut run)?
458            }
459            OutputFormatKind::Markdown => {
460                format_by_kind::<Markdown>(&processor, &processor_citations, &mut run)?
461            }
462        };
463        processor.register_nocite_ids(nocite_ids, &mut run);
464        let run = run.finalize();
465        let bibliography = match self.output_format {
466            OutputFormatKind::Plain => format_bibliography::<PlainText>(
467                &processor,
468                self.output_format,
469                self.document_options.as_ref(),
470                &run,
471            )?,
472            OutputFormatKind::Html => format_bibliography::<Html>(
473                &processor,
474                self.output_format,
475                self.document_options.as_ref(),
476                &run,
477            )?,
478            OutputFormatKind::Djot => format_bibliography::<Djot>(
479                &processor,
480                self.output_format,
481                self.document_options.as_ref(),
482                &run,
483            )?,
484            OutputFormatKind::Latex => format_bibliography::<Latex>(
485                &processor,
486                self.output_format,
487                self.document_options.as_ref(),
488                &run,
489            )?,
490            OutputFormatKind::Typst => format_bibliography::<Typst>(
491                &processor,
492                self.output_format,
493                self.document_options.as_ref(),
494                &run,
495            )?,
496            OutputFormatKind::Markdown => format_bibliography::<Markdown>(
497                &processor,
498                self.output_format,
499                self.document_options.as_ref(),
500                &run,
501            )?,
502        };
503
504        Ok(SessionRenderResult {
505            formatted_citations,
506            bibliography,
507            warnings,
508        })
509    }
510
511    fn citation_index(&self, citation_id: &str) -> Option<usize> {
512        self.citations
513            .iter()
514            .position(|citation| citation.id == citation_id)
515    }
516
517    fn resolve_insert_index(
518        &self,
519        position: Option<&CitationInsertPosition>,
520    ) -> Result<usize, DocumentSessionError> {
521        self.resolve_insert_index_in(&self.citations, position)
522    }
523
524    fn resolve_insert_index_in(
525        &self,
526        citations: &[CitationOccurrence],
527        position: Option<&CitationInsertPosition>,
528    ) -> Result<usize, DocumentSessionError> {
529        let Some(position) = position else {
530            return Ok(citations.len());
531        };
532        match (&position.after_citation_id, &position.before_citation_id) {
533            (None, None) => Ok(citations.len()),
534            (Some(after), None) => citations
535                .iter()
536                .position(|citation| citation.id == *after)
537                .map(|index| index + 1)
538                .ok_or_else(|| {
539                    DocumentSessionError::InvalidPosition(format!(
540                        "unknown after_citation_id '{after}'"
541                    ))
542                }),
543            (None, Some(before)) => citations
544                .iter()
545                .position(|citation| citation.id == *before)
546                .ok_or_else(|| {
547                    DocumentSessionError::InvalidPosition(format!(
548                        "unknown before_citation_id '{before}'"
549                    ))
550                }),
551            (Some(after), Some(before)) => {
552                let after_index = citations
553                    .iter()
554                    .position(|citation| citation.id == *after)
555                    .ok_or_else(|| {
556                        DocumentSessionError::InvalidPosition(format!(
557                            "unknown after_citation_id '{after}'"
558                        ))
559                    })?;
560                let before_index = citations
561                    .iter()
562                    .position(|citation| citation.id == *before)
563                    .ok_or_else(|| {
564                        DocumentSessionError::InvalidPosition(format!(
565                            "unknown before_citation_id '{before}'"
566                        ))
567                    })?;
568                if after_index + 1 == before_index {
569                    Ok(before_index)
570                } else {
571                    Err(DocumentSessionError::InvalidPosition(format!(
572                        "after_citation_id '{after}' and before_citation_id '{before}' are not adjacent"
573                    )))
574                }
575            }
576        }
577    }
578}
579
580#[derive(Debug)]
581struct SessionRenderResult {
582    formatted_citations: Vec<FormattedCitation>,
583    bibliography: FormattedBibliography,
584    warnings: Vec<Warning>,
585}
586
587fn diff_formatted_citations(
588    old: &[FormattedCitation],
589    new: &[FormattedCitation],
590) -> Vec<FormattedCitation> {
591    let old_by_id: HashMap<&str, &FormattedCitation> = old
592        .iter()
593        .map(|citation| (citation.id.as_str(), citation))
594        .collect();
595    new.iter()
596        .filter(|citation| {
597            old_by_id.get(citation.id.as_str()).is_none_or(|previous| {
598                previous.text != citation.text || previous.ref_ids != citation.ref_ids
599            })
600        })
601        .cloned()
602        .collect()
603}
604
605fn renumbering_occurred(
606    style: &Style,
607    old_citations: &[CitationOccurrence],
608    new_citations: &[CitationOccurrence],
609    old_formatted: &[FormattedCitation],
610    new_formatted: &[FormattedCitation],
611) -> bool {
612    if note_numbers_shifted(old_citations, new_citations) {
613        return true;
614    }
615    if !uses_numeric_labels(style) {
616        return false;
617    }
618    let old_by_id: HashMap<&str, &FormattedCitation> = old_formatted
619        .iter()
620        .map(|citation| (citation.id.as_str(), citation))
621        .collect();
622    let old_occurrences_by_id: HashMap<&str, &CitationOccurrence> = old_citations
623        .iter()
624        .map(|citation| (citation.id.as_str(), citation))
625        .collect();
626    let new_occurrences_by_id: HashMap<&str, &CitationOccurrence> = new_citations
627        .iter()
628        .map(|citation| (citation.id.as_str(), citation))
629        .collect();
630    new_formatted.iter().any(|citation| {
631        let Some(previous) = old_by_id.get(citation.id.as_str()) else {
632            return false;
633        };
634        if previous.text == citation.text {
635            return false;
636        }
637        let Some(old_occurrence) = old_occurrences_by_id.get(citation.id.as_str()) else {
638            return false;
639        };
640        let Some(new_occurrence) = new_occurrences_by_id.get(citation.id.as_str()) else {
641            return false;
642        };
643        *old_occurrence == *new_occurrence
644    })
645}
646
647fn note_numbers_shifted(
648    old_citations: &[CitationOccurrence],
649    new_citations: &[CitationOccurrence],
650) -> bool {
651    let old_by_id: HashMap<&str, Option<u32>> = old_citations
652        .iter()
653        .map(|citation| (citation.id.as_str(), citation.note_number))
654        .collect();
655    new_citations.iter().any(|citation| {
656        old_by_id
657            .get(citation.id.as_str())
658            .is_some_and(|old_note_number| *old_note_number != citation.note_number)
659    })
660}
661
662fn uses_numeric_labels(style: &Style) -> bool {
663    matches!(
664        style
665            .options
666            .as_ref()
667            .and_then(|options| options.processing.as_ref()),
668        Some(Processing::Numeric | Processing::Label(_))
669    )
670}
671
672#[cfg(test)]
673#[allow(
674    clippy::unwrap_used,
675    clippy::expect_used,
676    clippy::panic,
677    clippy::indexing_slicing,
678    reason = "test code uses assertions and panic"
679)]
680mod tests {
681    use super::*;
682    use crate::reference::Bibliography;
683    use crate::{
684        Config, Contributor, ContributorForm, ContributorList, ContributorRole, DateForm,
685        MultilingualString, Processing, Rendering, StructuredName, TemplateDateVariable,
686    };
687    use citum_schema::reference::{DateValue, InputReference, Monograph, MonographType, Title};
688    use citum_schema::template::{TemplateTitle, TitleType};
689    use citum_schema::{
690        BibliographySpec, CitationSpec, StyleInfo, TemplateComponent, TemplateContributor,
691        TemplateDate, WrapPunctuation,
692    };
693
694    fn style() -> Style {
695        Style {
696            info: StyleInfo {
697                title: Some("Session Test Style".to_string()),
698                id: Some("session-test".into()),
699                ..Default::default()
700            },
701            options: Some(Config {
702                processing: Some(Processing::AuthorDate),
703                ..Default::default()
704            }),
705            citation: Some(CitationSpec {
706                template: Some(
707                    vec![
708                        TemplateComponent::Contributor(TemplateContributor {
709                            contributor: ContributorRole::Author.into(),
710                            form: ContributorForm::Short,
711                            rendering: Rendering::default(),
712                            ..Default::default()
713                        }),
714                        TemplateComponent::Date(TemplateDate {
715                            date: TemplateDateVariable::Issued,
716                            form: DateForm::Year,
717                            rendering: Rendering {
718                                prefix: Some(", ".into()),
719                                ..Default::default()
720                            },
721                            ..Default::default()
722                        }),
723                    ]
724                    .into(),
725                ),
726                wrap: Some(WrapPunctuation::Parentheses.into()),
727                ..Default::default()
728            }),
729            ..Default::default()
730        }
731    }
732
733    fn numeric_style() -> Style {
734        Style {
735            info: StyleInfo {
736                title: Some("Numeric Session Test Style".to_string()),
737                id: Some("numeric-session-test".into()),
738                ..Default::default()
739            },
740            options: Some(Config {
741                processing: Some(Processing::Numeric),
742                ..Default::default()
743            }),
744            ..Default::default()
745        }
746    }
747
748    fn refs() -> RefsInput {
749        let mut refs = Bibliography::new();
750        refs.insert(
751            "smith2020".to_string(),
752            reference("smith2020", "Smith", "2020"),
753        );
754        refs.insert("doe2021".to_string(), reference("doe2021", "Doe", "2021"));
755        refs.insert("roe2022".to_string(), reference("roe2022", "Roe", "2022"));
756        RefsInput::Json(serde_json::to_value(refs).expect("refs should serialize"))
757    }
758
759    fn reference(id: &str, family: &str, issued: &str) -> InputReference {
760        InputReference::Monograph(Box::new(Monograph {
761            id: Some(id.into()),
762            r#type: MonographType::Book,
763            title: Some(Title::Single(format!("{family} Work"))),
764            author: Some(Contributor::ContributorList(ContributorList(vec![
765                Contributor::StructuredName(StructuredName {
766                    family: MultilingualString::Simple(family.to_string()),
767                    given: MultilingualString::Simple("Alex".to_string()),
768                    suffix: None,
769                    dropping_particle: None,
770                    non_dropping_particle: None,
771                }),
772            ]))),
773            issued: DateValue::new(issued.to_string()),
774            ..Default::default()
775        }))
776    }
777
778    fn citation(citation_id: &str, ref_id: &str) -> CitationOccurrence {
779        CitationOccurrence {
780            id: citation_id.to_string(),
781            items: vec![CitationOccurrenceItem {
782                id: ref_id.to_string(),
783                locator: None,
784                prefix: None,
785                suffix: None,
786                integral_name_state: None,
787                org_abbreviation_state: None,
788            }],
789            mode: None,
790            note_number: None,
791            suppress_author: None,
792            grouped: None,
793            prefix: None,
794            suffix: None,
795            sentence_start: None,
796        }
797    }
798
799    fn formatted(citation_id: &str, text: &str) -> FormattedCitation {
800        FormattedCitation {
801            id: citation_id.to_string(),
802            text: text.to_string(),
803            ref_ids: vec!["smith2020".to_string()],
804        }
805    }
806
807    fn session() -> DocumentSession {
808        let mut session = DocumentSession::new(
809            style(),
810            StyleInput::Yaml(String::new()),
811            None,
812            OutputFormatKind::Plain,
813            None,
814        );
815        session.put_references(refs()).expect("refs should resolve");
816        session
817    }
818
819    #[test]
820    fn session_batch_insert_returns_complete_changed_set() {
821        let mut session = session();
822        let result = session
823            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
824            .expect("batch insert should render");
825
826        assert_eq!(result.version, 1);
827        assert_eq!(result.affected_citations.len(), 2);
828        assert_eq!(session.get_citations().len(), 2);
829        assert!(!result.renumbering_occurred);
830    }
831
832    #[test]
833    fn author_date_insert_does_not_report_renumbering() {
834        let mut session = session();
835        session
836            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
837            .expect("batch insert should render");
838        let result = session
839            .insert_citation(
840                citation("c0", "roe2022"),
841                Some(CitationInsertPosition {
842                    after_citation_id: None,
843                    before_citation_id: Some("c1".to_string()),
844                }),
845            )
846            .expect("insert should render");
847
848        assert!(!result.renumbering_occurred);
849        assert_eq!(
850            result
851                .affected_citations
852                .iter()
853                .map(|citation| citation.id.as_str())
854                .collect::<Vec<_>>(),
855            vec!["c0"]
856        );
857    }
858
859    #[test]
860    fn note_number_shift_reports_renumbering() {
861        let mut session = session();
862        let mut first = citation("c1", "smith2020");
863        first.note_number = Some(1);
864        session
865            .insert_citations_batch(vec![first])
866            .expect("batch insert should render");
867        let mut updated = citation("c1", "smith2020");
868        updated.note_number = Some(2);
869        let result = session
870            .update_citation("c1", updated, None)
871            .expect("update should render");
872
873        assert!(result.renumbering_occurred);
874    }
875
876    #[test]
877    fn numeric_own_payload_edit_does_not_report_renumbering() {
878        let old = citation("c1", "smith2020");
879        let mut new = old.clone();
880        new.suffix = Some(", p. 12".to_string());
881
882        assert!(!renumbering_occurred(
883            &numeric_style(),
884            &[old],
885            &[new],
886            &[formatted("c1", "[1]")],
887            &[formatted("c1", "[1], p. 12")],
888        ));
889    }
890
891    #[test]
892    fn numeric_unchanged_existing_output_shift_reports_renumbering() {
893        let unchanged = citation("c1", "smith2020");
894
895        assert!(renumbering_occurred(
896            &numeric_style(),
897            std::slice::from_ref(&unchanged),
898            std::slice::from_ref(&unchanged),
899            &[formatted("c1", "[1]")],
900            &[formatted("c1", "[2]")],
901        ));
902    }
903
904    #[test]
905    fn preview_does_not_mutate_session() {
906        use citum_schema::data::citation::CitationMode;
907
908        let mut session = DocumentSession::new(
909            integral_name_style(),
910            StyleInput::Yaml(String::new()),
911            None,
912            OutputFormatKind::Plain,
913            None,
914        );
915        session
916            .put_references(smith_refs())
917            .expect("refs should resolve");
918        session
919            .insert_citations_batch(vec![citation("c1", "smith2020")])
920            .expect("batch insert should render");
921        let before_version = session.version();
922        let before_citations = session.get_citations();
923        let preview_items = citation("preview", "smith2020").items;
924
925        let default_preview = session
926            .preview_citation(preview_items.clone(), None, None)
927            .expect("preview should render");
928        let integral_preview = session
929            .preview_citation(preview_items, Some(CitationMode::Integral), None)
930            .expect("integral preview should render");
931
932        assert!(!default_preview.preview.is_empty());
933        assert!(!integral_preview.preview.is_empty());
934        assert_ne!(default_preview.preview, integral_preview.preview);
935        assert_eq!(session.version(), before_version);
936        assert_eq!(session.get_citations().len(), before_citations.len());
937    }
938
939    /// Two sessions opened from the same base style but with different overrides
940    /// must produce divergent output for the same two-author citation.
941    #[test]
942    fn session_style_override_produces_divergent_output() {
943        use crate::api::apply_style_overrides;
944        use citum_schema::options::{AndOptions, ContributorConfig};
945
946        // base style with explicit `and: text`
947        let mut base_style = style();
948        assert!(
949            base_style.options.is_some(),
950            "style() must return options: Some(...) for this test's contributor setup to take effect"
951        );
952        if let Some(opts) = base_style.options.as_mut() {
953            opts.contributors = Some(ContributorConfig {
954                and: Some(AndOptions::Text),
955                ..Default::default()
956            });
957        }
958
959        // two-author reference via inline YAML
960        let two_author_refs = RefsInput::Yaml(
961            r#"duo2024:
962  class: monograph
963  id: duo2024
964  type: book
965  title: Duo Work
966  issued: "2024"
967  author:
968    - family: Smith
969      given: Alice
970    - family: Jones
971      given: Bob
972"#
973            .to_string(),
974        );
975
976        // session 1: no override — uses "and" text
977        let mut session_base = DocumentSession::new(
978            base_style.clone(),
979            StyleInput::Yaml(String::new()),
980            None,
981            OutputFormatKind::Plain,
982            None,
983        );
984        session_base
985            .put_references(two_author_refs.clone())
986            .expect("refs should resolve");
987        let result_base = session_base
988            .insert_citations_batch(vec![citation("c1", "duo2024")])
989            .expect("base session should render");
990        let text_base = result_base.affected_citations[0].text.clone();
991
992        // session 2: override switches to "&" symbol
993        let mut style_overridden = base_style.clone();
994        apply_style_overrides(
995            &mut style_overridden,
996            "options:\n  contributors:\n    and: symbol\n",
997        )
998        .expect("override should parse");
999        let mut session_override = DocumentSession::new(
1000            style_overridden,
1001            StyleInput::Yaml(String::new()),
1002            None,
1003            OutputFormatKind::Plain,
1004            None,
1005        );
1006        session_override
1007            .put_references(two_author_refs)
1008            .expect("refs should resolve");
1009        let result_override = session_override
1010            .insert_citations_batch(vec![citation("c1", "duo2024")])
1011            .expect("override session should render");
1012        let text_override = result_override.affected_citations[0].text.clone();
1013
1014        assert!(
1015            text_base.contains("and"),
1016            "base session should use text 'and', got: {text_base:?}"
1017        );
1018        assert!(
1019            text_override.contains('&'),
1020            "override session should use '&', got: {text_override:?}"
1021        );
1022        assert_ne!(
1023            text_base, text_override,
1024            "sessions with different overrides should produce different output"
1025        );
1026    }
1027
1028    // --- integral_name_memory wiring ---
1029
1030    /// Build a style with integral-name memory configured (scope=Document,
1031    /// subsequent_form=Short) and an integral sub-template rendering Long names.
1032    fn integral_name_style() -> Style {
1033        use citum_schema::options::{
1034            IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1035        };
1036        Style {
1037            info: StyleInfo {
1038                title: Some("Integral Name Memory Session Test".to_string()),
1039                id: Some("integral-name-memory-session-test".into()),
1040                ..Default::default()
1041            },
1042            options: Some(Config {
1043                processing: Some(Processing::AuthorDate),
1044                integral_name_memory: Some(IntegralNameMemoryConfig {
1045                    scope: Some(IntegralNameScope::Document),
1046                    contexts: Some(IntegralNameContexts::BodyAndNotes),
1047                    subsequent_form: Some(SubsequentNameForm::Short),
1048                    ..Default::default()
1049                }),
1050                ..Default::default()
1051            }),
1052            citation: Some(CitationSpec {
1053                integral: Some(Box::new(CitationSpec {
1054                    template: Some(
1055                        vec![TemplateComponent::Contributor(TemplateContributor {
1056                            contributor: ContributorRole::Author.into(),
1057                            form: ContributorForm::Long,
1058                            rendering: Rendering::default(),
1059                            ..Default::default()
1060                        })]
1061                        .into(),
1062                    ),
1063                    ..Default::default()
1064                })),
1065                template: Some(
1066                    vec![
1067                        TemplateComponent::Contributor(TemplateContributor {
1068                            contributor: ContributorRole::Author.into(),
1069                            form: ContributorForm::Short,
1070                            rendering: Rendering::default(),
1071                            ..Default::default()
1072                        }),
1073                        TemplateComponent::Date(TemplateDate {
1074                            date: TemplateDateVariable::Issued,
1075                            form: DateForm::Year,
1076                            rendering: Rendering {
1077                                prefix: Some(", ".into()),
1078                                ..Default::default()
1079                            },
1080                            ..Default::default()
1081                        }),
1082                    ]
1083                    .into(),
1084                ),
1085                wrap: Some(WrapPunctuation::Parentheses.into()),
1086                ..Default::default()
1087            }),
1088            ..Default::default()
1089        }
1090    }
1091
1092    fn smith_refs() -> RefsInput {
1093        RefsInput::Yaml(
1094            r#"smith2020:
1095  class: monograph
1096  id: smith2020
1097  type: book
1098  title: Smith Book
1099  issued: "2020"
1100  author:
1101    - family: Smith
1102      given: John
1103"#
1104            .to_string(),
1105        )
1106    }
1107
1108    fn integral_citation(id: &str, ref_id: &str) -> CitationOccurrence {
1109        CitationOccurrence {
1110            id: id.to_string(),
1111            items: vec![crate::api::CitationOccurrenceItem {
1112                id: ref_id.to_string(),
1113                locator: None,
1114                prefix: None,
1115                suffix: None,
1116                integral_name_state: None,
1117                org_abbreviation_state: None,
1118            }],
1119            mode: Some(citum_schema::data::citation::CitationMode::Integral),
1120            note_number: None,
1121            suppress_author: None,
1122            grouped: None,
1123            prefix: None,
1124            suffix: None,
1125            sentence_start: None,
1126        }
1127    }
1128
1129    #[test]
1130    fn session_document_options_integral_name_memory_first_full_then_short() {
1131        use crate::processor::document::DocumentIntegralNameOverride;
1132
1133        let mut session = DocumentSession::new(
1134            integral_name_style(),
1135            StyleInput::Yaml(String::new()),
1136            None,
1137            OutputFormatKind::Plain,
1138            Some(DocumentOptions {
1139                integral_name_memory: Some(DocumentIntegralNameOverride {
1140                    enabled: Some(true),
1141                    ..Default::default()
1142                }),
1143                ..Default::default()
1144            }),
1145        );
1146        session
1147            .put_references(smith_refs())
1148            .expect("refs should resolve");
1149        let result = session
1150            .insert_citations_batch(vec![
1151                integral_citation("c1", "smith2020"),
1152                integral_citation("c2", "smith2020"),
1153            ])
1154            .expect("should render");
1155
1156        assert!(
1157            !result
1158                .warnings
1159                .iter()
1160                .any(|w| w.code == "integral_name_memory_not_applied"),
1161            "stale warning must not appear: {:?}",
1162            result.warnings
1163        );
1164
1165        let first = result
1166            .affected_citations
1167            .iter()
1168            .find(|c| c.id == "c1")
1169            .expect("c1 should be in result");
1170        let second = result
1171            .affected_citations
1172            .iter()
1173            .find(|c| c.id == "c2")
1174            .expect("c2 should be in result");
1175
1176        assert_eq!(
1177            first.text, "John Smith",
1178            "first integral cite should render full name form"
1179        );
1180        assert_eq!(
1181            second.text, "Smith",
1182            "second integral cite of same author should render short form"
1183        );
1184    }
1185
1186    #[test]
1187    fn session_document_options_integral_name_memory_disabled_keeps_full_form() {
1188        use crate::processor::document::DocumentIntegralNameOverride;
1189
1190        let mut session = DocumentSession::new(
1191            integral_name_style(),
1192            StyleInput::Yaml(String::new()),
1193            None,
1194            OutputFormatKind::Plain,
1195            Some(DocumentOptions {
1196                integral_name_memory: Some(DocumentIntegralNameOverride {
1197                    enabled: Some(false),
1198                    ..Default::default()
1199                }),
1200                ..Default::default()
1201            }),
1202        );
1203        session
1204            .put_references(smith_refs())
1205            .expect("refs should resolve");
1206        let result = session
1207            .insert_citations_batch(vec![
1208                integral_citation("c1", "smith2020"),
1209                integral_citation("c2", "smith2020"),
1210            ])
1211            .expect("should render");
1212
1213        let first = result
1214            .affected_citations
1215            .iter()
1216            .find(|c| c.id == "c1")
1217            .expect("c1 should be in result");
1218        let second = result
1219            .affected_citations
1220            .iter()
1221            .find(|c| c.id == "c2")
1222            .expect("c2 should be in result");
1223
1224        // Memory disabled — both occurrences render the natural Long form.
1225        assert_eq!(
1226            first.text, "John Smith",
1227            "first integral cite with disabled memory: {}",
1228            first.text
1229        );
1230        assert_eq!(
1231            second.text, "John Smith",
1232            "second integral cite should also be full when memory is disabled"
1233        );
1234    }
1235
1236    #[test]
1237    fn session_style_native_integral_name_memory_applied_without_document_override() {
1238        // Style has integral_name_memory in its own options; no document_options
1239        // override is supplied. The flat session API must still annotate.
1240        let mut session = DocumentSession::new(
1241            integral_name_style(),
1242            StyleInput::Yaml(String::new()),
1243            None,
1244            OutputFormatKind::Plain,
1245            None,
1246        );
1247        session
1248            .put_references(smith_refs())
1249            .expect("refs should resolve");
1250        let result = session
1251            .insert_citations_batch(vec![
1252                integral_citation("c1", "smith2020"),
1253                integral_citation("c2", "smith2020"),
1254            ])
1255            .expect("should render");
1256
1257        let first = result
1258            .affected_citations
1259            .iter()
1260            .find(|c| c.id == "c1")
1261            .expect("c1 should be in result");
1262        let second = result
1263            .affected_citations
1264            .iter()
1265            .find(|c| c.id == "c2")
1266            .expect("c2 should be in result");
1267
1268        assert_eq!(
1269            first.text, "John Smith",
1270            "first integral cite should render full name form"
1271        );
1272        assert_eq!(
1273            second.text, "Smith",
1274            "second integral cite should render short form from style-native config"
1275        );
1276    }
1277
1278    fn style_with_bibliography() -> Style {
1279        let mut s = style();
1280        s.bibliography = Some(BibliographySpec {
1281            template: Some(
1282                vec![TemplateComponent::Title(TemplateTitle {
1283                    title: TitleType::Primary,
1284                    ..Default::default()
1285                })]
1286                .into(),
1287            ),
1288            ..Default::default()
1289        });
1290        s
1291    }
1292
1293    #[test]
1294    fn set_nocite_puts_ref_in_bibliography_not_in_formatted_citations() {
1295        // given: a session with smith2020 cited in-text and roe2022 nocite-only
1296        let mut session = DocumentSession::new(
1297            style_with_bibliography(),
1298            StyleInput::Yaml(String::new()),
1299            None,
1300            OutputFormatKind::Plain,
1301            None,
1302        );
1303        session.put_references(refs()).expect("refs should resolve");
1304        session
1305            .insert_citations_batch(vec![citation("c1", "smith2020")])
1306            .expect("citation insert should succeed");
1307
1308        // when: roe2022 is registered as nocite
1309        let result = session
1310            .set_nocite(vec!["roe2022".to_string()])
1311            .expect("set_nocite should succeed");
1312
1313        // then: roe2022 appears in bibliography entries but not in any formatted citation
1314        assert!(
1315            result
1316                .bibliography
1317                .entries
1318                .iter()
1319                .any(|e| e.id == "roe2022"),
1320            "nocite ref should appear in bibliography entries"
1321        );
1322        assert!(
1323            result
1324                .affected_citations
1325                .iter()
1326                .all(|c| c.text != "roe2022" && !c.ref_ids.iter().any(|r| r == "roe2022")),
1327            "nocite ref should not appear in any formatted citation"
1328        );
1329        // and: the uncited, non-nocite ref (doe2021) is absent from bibliography
1330        assert!(
1331            !result
1332                .bibliography
1333                .entries
1334                .iter()
1335                .any(|e| e.id == "doe2021"),
1336            "non-cited, non-nocite ref should not appear in bibliography"
1337        );
1338    }
1339
1340    #[test]
1341    fn put_references_with_malformed_input_returns_error() {
1342        // given: a fresh session
1343        let mut session = DocumentSession::new(
1344            style(),
1345            StyleInput::Yaml(String::new()),
1346            None,
1347            OutputFormatKind::Plain,
1348            None,
1349        );
1350
1351        // when: references are supplied as unparseable YAML
1352        let result = session.put_references(RefsInput::Yaml("not: [valid".to_string()));
1353
1354        // then: the parse error surfaces at put time, not on the next mutation
1355        assert!(
1356            matches!(result, Err(DocumentSessionError::Format(_))),
1357            "malformed refs input should error at put_references"
1358        );
1359    }
1360
1361    #[test]
1362    fn put_references_replaces_cached_reference_set() {
1363        // given: a session rendering smith2020 from the initial reference set
1364        let mut session = session();
1365        let first = session
1366            .insert_citations_batch(vec![citation("c1", "smith2020")])
1367            .expect("initial insert should render");
1368        let first_text = first.affected_citations[0].text.clone();
1369
1370        // when: put_references replaces the set with a different smith2020 year
1371        let mut replacement = Bibliography::new();
1372        replacement.insert(
1373            "smith2020".to_string(),
1374            reference("smith2020", "Smith", "2024"),
1375        );
1376        session
1377            .put_references(RefsInput::Json(
1378                serde_json::to_value(replacement).expect("replacement refs should serialize"),
1379            ))
1380            .expect("replacement refs should resolve");
1381        let second = session
1382            .insert_citations_batch(vec![citation("c1", "smith2020")])
1383            .expect("re-render should succeed");
1384
1385        // then: subsequent renders use the replaced (re-resolved) references —
1386        // identical output except the issued year
1387        assert_eq!(
1388            second.affected_citations[0].text,
1389            first_text.replace("2020", "2024"),
1390            "render after put_references should reflect the replaced reference set"
1391        );
1392        assert_ne!(second.affected_citations[0].text, first_text);
1393    }
1394}