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, term_locale_fallback_warnings, unknown_enum_warnings,
24    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
372        if let Some(opts) = &self.document_options {
373            // Rebuild the processor with the document-level integral-name override
374            // before applying scalar field mutations so those are not lost.
375            if let Some(new_proc) = processor
376                .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
377            {
378                processor = new_proc;
379            }
380            if let Some(show_semantics) = opts.show_semantics {
381                processor.show_semantics = show_semantics;
382            }
383            if let Some(inject_ast) = opts.inject_ast_indices {
384                processor.set_inject_ast_indices(inject_ast);
385            }
386            if let Some(abbr_map) = opts.abbreviation_map.clone() {
387                processor.abbreviation_map = Some(abbr_map);
388            }
389        }
390
391        let mut processor_citations: Vec<Citation> = Vec::new();
392        for occ in citations.iter().cloned() {
393            let mut citation: Citation = occ.into();
394            citation.items.retain(|item| {
395                if processor.bibliography.contains_key(&item.id) {
396                    true
397                } else {
398                    warnings.push(Warning {
399                        level: WarningLevel::Warning,
400                        code: "missing_ref".to_string(),
401                        citation_id: citation.id.clone(),
402                        ref_id: Some(item.id.clone()),
403                        message: format!("Reference '{}' not found in bibliography", item.id),
404                    });
405                    false
406                }
407            });
408            processor_citations.push(citation);
409        }
410
411        // Annotate integral-name First/Subsequent state from the processor's
412        // effective config (no document structure available; all citations share
413        // document scope). Safe no-op when no memory config is present.
414        processor.annotate_flat_integral_name_states(&mut processor_citations);
415
416        // Register nocite IDs: validate against bibliography, warn on missing, then
417        // add to cited_ids so they appear in bibliography entries but produce no
418        // citation text.
419        let nocite_ids: Vec<String> = self
420            .nocite
421            .iter()
422            .filter_map(|id| {
423                if processor.bibliography.contains_key(id) {
424                    Some(id.clone())
425                } else {
426                    warnings.push(Warning {
427                        level: WarningLevel::Warning,
428                        code: "nocite_missing_ref".to_string(),
429                        citation_id: None,
430                        ref_id: Some(id.clone()),
431                        message: format!("Nocite reference '{id}' not found in bibliography"),
432                    });
433                    None
434                }
435            })
436            .collect();
437
438        // One run threads registration across citations and nocite
439        // registration, then finalizes once for bibliography rendering.
440        let mut run = processor.begin_run();
441
442        let formatted_citations = match self.output_format {
443            OutputFormatKind::Plain => {
444                format_by_kind::<PlainText>(&processor, &processor_citations, &mut run)?
445            }
446            OutputFormatKind::Html => {
447                format_by_kind::<Html>(&processor, &processor_citations, &mut run)?
448            }
449            OutputFormatKind::Djot => {
450                format_by_kind::<Djot>(&processor, &processor_citations, &mut run)?
451            }
452            OutputFormatKind::Latex => {
453                format_by_kind::<Latex>(&processor, &processor_citations, &mut run)?
454            }
455            OutputFormatKind::Typst => {
456                format_by_kind::<Typst>(&processor, &processor_citations, &mut run)?
457            }
458            OutputFormatKind::Markdown => {
459                format_by_kind::<Markdown>(&processor, &processor_citations, &mut run)?
460            }
461        };
462        processor.register_nocite_ids(nocite_ids, &mut run);
463        let run = run.finalize();
464        let bibliography = match self.output_format {
465            OutputFormatKind::Plain => format_bibliography::<PlainText>(
466                &processor,
467                self.output_format,
468                self.document_options.as_ref(),
469                &run,
470            )?,
471            OutputFormatKind::Html => format_bibliography::<Html>(
472                &processor,
473                self.output_format,
474                self.document_options.as_ref(),
475                &run,
476            )?,
477            OutputFormatKind::Djot => format_bibliography::<Djot>(
478                &processor,
479                self.output_format,
480                self.document_options.as_ref(),
481                &run,
482            )?,
483            OutputFormatKind::Latex => format_bibliography::<Latex>(
484                &processor,
485                self.output_format,
486                self.document_options.as_ref(),
487                &run,
488            )?,
489            OutputFormatKind::Typst => format_bibliography::<Typst>(
490                &processor,
491                self.output_format,
492                self.document_options.as_ref(),
493                &run,
494            )?,
495            OutputFormatKind::Markdown => format_bibliography::<Markdown>(
496                &processor,
497                self.output_format,
498                self.document_options.as_ref(),
499                &run,
500            )?,
501        };
502
503        Ok(SessionRenderResult {
504            formatted_citations,
505            bibliography,
506            warnings,
507        })
508    }
509
510    fn citation_index(&self, citation_id: &str) -> Option<usize> {
511        self.citations
512            .iter()
513            .position(|citation| citation.id == citation_id)
514    }
515
516    fn resolve_insert_index(
517        &self,
518        position: Option<&CitationInsertPosition>,
519    ) -> Result<usize, DocumentSessionError> {
520        self.resolve_insert_index_in(&self.citations, position)
521    }
522
523    fn resolve_insert_index_in(
524        &self,
525        citations: &[CitationOccurrence],
526        position: Option<&CitationInsertPosition>,
527    ) -> Result<usize, DocumentSessionError> {
528        let Some(position) = position else {
529            return Ok(citations.len());
530        };
531        match (&position.after_citation_id, &position.before_citation_id) {
532            (None, None) => Ok(citations.len()),
533            (Some(after), None) => citations
534                .iter()
535                .position(|citation| citation.id == *after)
536                .map(|index| index + 1)
537                .ok_or_else(|| {
538                    DocumentSessionError::InvalidPosition(format!(
539                        "unknown after_citation_id '{after}'"
540                    ))
541                }),
542            (None, Some(before)) => citations
543                .iter()
544                .position(|citation| citation.id == *before)
545                .ok_or_else(|| {
546                    DocumentSessionError::InvalidPosition(format!(
547                        "unknown before_citation_id '{before}'"
548                    ))
549                }),
550            (Some(after), Some(before)) => {
551                let after_index = citations
552                    .iter()
553                    .position(|citation| citation.id == *after)
554                    .ok_or_else(|| {
555                        DocumentSessionError::InvalidPosition(format!(
556                            "unknown after_citation_id '{after}'"
557                        ))
558                    })?;
559                let before_index = citations
560                    .iter()
561                    .position(|citation| citation.id == *before)
562                    .ok_or_else(|| {
563                        DocumentSessionError::InvalidPosition(format!(
564                            "unknown before_citation_id '{before}'"
565                        ))
566                    })?;
567                if after_index + 1 == before_index {
568                    Ok(before_index)
569                } else {
570                    Err(DocumentSessionError::InvalidPosition(format!(
571                        "after_citation_id '{after}' and before_citation_id '{before}' are not adjacent"
572                    )))
573                }
574            }
575        }
576    }
577}
578
579#[derive(Debug)]
580struct SessionRenderResult {
581    formatted_citations: Vec<FormattedCitation>,
582    bibliography: FormattedBibliography,
583    warnings: Vec<Warning>,
584}
585
586fn diff_formatted_citations(
587    old: &[FormattedCitation],
588    new: &[FormattedCitation],
589) -> Vec<FormattedCitation> {
590    let old_by_id: HashMap<&str, &FormattedCitation> = old
591        .iter()
592        .map(|citation| (citation.id.as_str(), citation))
593        .collect();
594    new.iter()
595        .filter(|citation| {
596            old_by_id.get(citation.id.as_str()).is_none_or(|previous| {
597                previous.text != citation.text || previous.ref_ids != citation.ref_ids
598            })
599        })
600        .cloned()
601        .collect()
602}
603
604fn renumbering_occurred(
605    style: &Style,
606    old_citations: &[CitationOccurrence],
607    new_citations: &[CitationOccurrence],
608    old_formatted: &[FormattedCitation],
609    new_formatted: &[FormattedCitation],
610) -> bool {
611    if note_numbers_shifted(old_citations, new_citations) {
612        return true;
613    }
614    if !uses_numeric_labels(style) {
615        return false;
616    }
617    let old_by_id: HashMap<&str, &FormattedCitation> = old_formatted
618        .iter()
619        .map(|citation| (citation.id.as_str(), citation))
620        .collect();
621    let old_occurrences_by_id: HashMap<&str, &CitationOccurrence> = old_citations
622        .iter()
623        .map(|citation| (citation.id.as_str(), citation))
624        .collect();
625    let new_occurrences_by_id: HashMap<&str, &CitationOccurrence> = new_citations
626        .iter()
627        .map(|citation| (citation.id.as_str(), citation))
628        .collect();
629    new_formatted.iter().any(|citation| {
630        let Some(previous) = old_by_id.get(citation.id.as_str()) else {
631            return false;
632        };
633        if previous.text == citation.text {
634            return false;
635        }
636        let Some(old_occurrence) = old_occurrences_by_id.get(citation.id.as_str()) else {
637            return false;
638        };
639        let Some(new_occurrence) = new_occurrences_by_id.get(citation.id.as_str()) else {
640            return false;
641        };
642        *old_occurrence == *new_occurrence
643    })
644}
645
646fn note_numbers_shifted(
647    old_citations: &[CitationOccurrence],
648    new_citations: &[CitationOccurrence],
649) -> bool {
650    let old_by_id: HashMap<&str, Option<u32>> = old_citations
651        .iter()
652        .map(|citation| (citation.id.as_str(), citation.note_number))
653        .collect();
654    new_citations.iter().any(|citation| {
655        old_by_id
656            .get(citation.id.as_str())
657            .is_some_and(|old_note_number| *old_note_number != citation.note_number)
658    })
659}
660
661fn uses_numeric_labels(style: &Style) -> bool {
662    matches!(
663        style
664            .options
665            .as_ref()
666            .and_then(|options| options.processing.as_ref()),
667        Some(Processing::Numeric | Processing::Label(_))
668    )
669}
670
671#[cfg(test)]
672#[allow(
673    clippy::unwrap_used,
674    clippy::expect_used,
675    clippy::panic,
676    clippy::indexing_slicing,
677    reason = "test code uses assertions and panic"
678)]
679mod tests {
680    use super::*;
681    use crate::reference::Bibliography;
682    use crate::{
683        Config, Contributor, ContributorForm, ContributorList, ContributorRole, DateForm,
684        MultilingualString, Processing, Rendering, StructuredName, TemplateDateVariable,
685    };
686    use citum_schema::reference::{DateValue, InputReference, Monograph, MonographType, Title};
687    use citum_schema::template::{TemplateTitle, TitleType};
688    use citum_schema::{
689        BibliographySpec, CitationSpec, StyleInfo, TemplateComponent, TemplateContributor,
690        TemplateDate, WrapPunctuation,
691    };
692
693    fn style() -> Style {
694        Style {
695            info: StyleInfo {
696                title: Some("Session Test Style".to_string()),
697                id: Some("session-test".into()),
698                ..Default::default()
699            },
700            options: Some(Config {
701                processing: Some(Processing::AuthorDate),
702                ..Default::default()
703            }),
704            citation: Some(CitationSpec {
705                template: Some(vec![
706                    TemplateComponent::Contributor(TemplateContributor {
707                        contributor: ContributorRole::Author.into(),
708                        form: ContributorForm::Short,
709                        rendering: Rendering::default(),
710                        ..Default::default()
711                    }),
712                    TemplateComponent::Date(TemplateDate {
713                        date: TemplateDateVariable::Issued,
714                        form: DateForm::Year,
715                        rendering: Rendering {
716                            prefix: Some(", ".into()),
717                            ..Default::default()
718                        },
719                        ..Default::default()
720                    }),
721                ]),
722                wrap: Some(WrapPunctuation::Parentheses.into()),
723                ..Default::default()
724            }),
725            ..Default::default()
726        }
727    }
728
729    fn numeric_style() -> Style {
730        Style {
731            info: StyleInfo {
732                title: Some("Numeric Session Test Style".to_string()),
733                id: Some("numeric-session-test".into()),
734                ..Default::default()
735            },
736            options: Some(Config {
737                processing: Some(Processing::Numeric),
738                ..Default::default()
739            }),
740            ..Default::default()
741        }
742    }
743
744    fn refs() -> RefsInput {
745        let mut refs = Bibliography::new();
746        refs.insert(
747            "smith2020".to_string(),
748            reference("smith2020", "Smith", "2020"),
749        );
750        refs.insert("doe2021".to_string(), reference("doe2021", "Doe", "2021"));
751        refs.insert("roe2022".to_string(), reference("roe2022", "Roe", "2022"));
752        RefsInput::Json(serde_json::to_value(refs).expect("refs should serialize"))
753    }
754
755    fn reference(id: &str, family: &str, issued: &str) -> InputReference {
756        InputReference::Monograph(Box::new(Monograph {
757            id: Some(id.into()),
758            r#type: MonographType::Book,
759            title: Some(Title::Single(format!("{family} Work"))),
760            author: Some(Contributor::ContributorList(ContributorList(vec![
761                Contributor::StructuredName(StructuredName {
762                    family: MultilingualString::Simple(family.to_string()),
763                    given: MultilingualString::Simple("Alex".to_string()),
764                    suffix: None,
765                    dropping_particle: None,
766                    non_dropping_particle: None,
767                }),
768            ]))),
769            issued: DateValue::new(issued.to_string()),
770            ..Default::default()
771        }))
772    }
773
774    fn citation(citation_id: &str, ref_id: &str) -> CitationOccurrence {
775        CitationOccurrence {
776            id: citation_id.to_string(),
777            items: vec![CitationOccurrenceItem {
778                id: ref_id.to_string(),
779                locator: None,
780                prefix: None,
781                suffix: None,
782                integral_name_state: None,
783                org_abbreviation_state: None,
784            }],
785            mode: None,
786            note_number: None,
787            suppress_author: None,
788            grouped: None,
789            prefix: None,
790            suffix: None,
791            sentence_start: None,
792        }
793    }
794
795    fn formatted(citation_id: &str, text: &str) -> FormattedCitation {
796        FormattedCitation {
797            id: citation_id.to_string(),
798            text: text.to_string(),
799            ref_ids: vec!["smith2020".to_string()],
800        }
801    }
802
803    fn session() -> DocumentSession {
804        let mut session = DocumentSession::new(
805            style(),
806            StyleInput::Yaml(String::new()),
807            None,
808            OutputFormatKind::Plain,
809            None,
810        );
811        session.put_references(refs()).expect("refs should resolve");
812        session
813    }
814
815    #[test]
816    fn session_batch_insert_returns_complete_changed_set() {
817        let mut session = session();
818        let result = session
819            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
820            .expect("batch insert should render");
821
822        assert_eq!(result.version, 1);
823        assert_eq!(result.affected_citations.len(), 2);
824        assert_eq!(session.get_citations().len(), 2);
825        assert!(!result.renumbering_occurred);
826    }
827
828    #[test]
829    fn author_date_insert_does_not_report_renumbering() {
830        let mut session = session();
831        session
832            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
833            .expect("batch insert should render");
834        let result = session
835            .insert_citation(
836                citation("c0", "roe2022"),
837                Some(CitationInsertPosition {
838                    after_citation_id: None,
839                    before_citation_id: Some("c1".to_string()),
840                }),
841            )
842            .expect("insert should render");
843
844        assert!(!result.renumbering_occurred);
845        assert_eq!(
846            result
847                .affected_citations
848                .iter()
849                .map(|citation| citation.id.as_str())
850                .collect::<Vec<_>>(),
851            vec!["c0"]
852        );
853    }
854
855    #[test]
856    fn note_number_shift_reports_renumbering() {
857        let mut session = session();
858        let mut first = citation("c1", "smith2020");
859        first.note_number = Some(1);
860        session
861            .insert_citations_batch(vec![first])
862            .expect("batch insert should render");
863        let mut updated = citation("c1", "smith2020");
864        updated.note_number = Some(2);
865        let result = session
866            .update_citation("c1", updated, None)
867            .expect("update should render");
868
869        assert!(result.renumbering_occurred);
870    }
871
872    #[test]
873    fn numeric_own_payload_edit_does_not_report_renumbering() {
874        let old = citation("c1", "smith2020");
875        let mut new = old.clone();
876        new.suffix = Some(", p. 12".to_string());
877
878        assert!(!renumbering_occurred(
879            &numeric_style(),
880            &[old],
881            &[new],
882            &[formatted("c1", "[1]")],
883            &[formatted("c1", "[1], p. 12")],
884        ));
885    }
886
887    #[test]
888    fn numeric_unchanged_existing_output_shift_reports_renumbering() {
889        let unchanged = citation("c1", "smith2020");
890
891        assert!(renumbering_occurred(
892            &numeric_style(),
893            std::slice::from_ref(&unchanged),
894            std::slice::from_ref(&unchanged),
895            &[formatted("c1", "[1]")],
896            &[formatted("c1", "[2]")],
897        ));
898    }
899
900    #[test]
901    fn preview_does_not_mutate_session() {
902        use citum_schema::data::citation::CitationMode;
903
904        let mut session = DocumentSession::new(
905            integral_name_style(),
906            StyleInput::Yaml(String::new()),
907            None,
908            OutputFormatKind::Plain,
909            None,
910        );
911        session
912            .put_references(smith_refs())
913            .expect("refs should resolve");
914        session
915            .insert_citations_batch(vec![citation("c1", "smith2020")])
916            .expect("batch insert should render");
917        let before_version = session.version();
918        let before_citations = session.get_citations();
919        let preview_items = citation("preview", "smith2020").items;
920
921        let default_preview = session
922            .preview_citation(preview_items.clone(), None, None)
923            .expect("preview should render");
924        let integral_preview = session
925            .preview_citation(preview_items, Some(CitationMode::Integral), None)
926            .expect("integral preview should render");
927
928        assert!(!default_preview.preview.is_empty());
929        assert!(!integral_preview.preview.is_empty());
930        assert_ne!(default_preview.preview, integral_preview.preview);
931        assert_eq!(session.version(), before_version);
932        assert_eq!(session.get_citations().len(), before_citations.len());
933    }
934
935    /// Two sessions opened from the same base style but with different overrides
936    /// must produce divergent output for the same two-author citation.
937    #[test]
938    fn session_style_override_produces_divergent_output() {
939        use crate::api::apply_style_overrides;
940        use citum_schema::options::{AndOptions, ContributorConfig};
941
942        // base style with explicit `and: text`
943        let mut base_style = style();
944        assert!(
945            base_style.options.is_some(),
946            "style() must return options: Some(...) for this test's contributor setup to take effect"
947        );
948        if let Some(opts) = base_style.options.as_mut() {
949            opts.contributors = Some(ContributorConfig {
950                and: Some(AndOptions::Text),
951                ..Default::default()
952            });
953        }
954
955        // two-author reference via inline YAML
956        let two_author_refs = RefsInput::Yaml(
957            r#"duo2024:
958  class: monograph
959  id: duo2024
960  type: book
961  title: Duo Work
962  issued: "2024"
963  author:
964    - family: Smith
965      given: Alice
966    - family: Jones
967      given: Bob
968"#
969            .to_string(),
970        );
971
972        // session 1: no override — uses "and" text
973        let mut session_base = DocumentSession::new(
974            base_style.clone(),
975            StyleInput::Yaml(String::new()),
976            None,
977            OutputFormatKind::Plain,
978            None,
979        );
980        session_base
981            .put_references(two_author_refs.clone())
982            .expect("refs should resolve");
983        let result_base = session_base
984            .insert_citations_batch(vec![citation("c1", "duo2024")])
985            .expect("base session should render");
986        let text_base = result_base.affected_citations[0].text.clone();
987
988        // session 2: override switches to "&" symbol
989        let mut style_overridden = base_style.clone();
990        apply_style_overrides(
991            &mut style_overridden,
992            "options:\n  contributors:\n    and: symbol\n",
993        )
994        .expect("override should parse");
995        let mut session_override = DocumentSession::new(
996            style_overridden,
997            StyleInput::Yaml(String::new()),
998            None,
999            OutputFormatKind::Plain,
1000            None,
1001        );
1002        session_override
1003            .put_references(two_author_refs)
1004            .expect("refs should resolve");
1005        let result_override = session_override
1006            .insert_citations_batch(vec![citation("c1", "duo2024")])
1007            .expect("override session should render");
1008        let text_override = result_override.affected_citations[0].text.clone();
1009
1010        assert!(
1011            text_base.contains("and"),
1012            "base session should use text 'and', got: {text_base:?}"
1013        );
1014        assert!(
1015            text_override.contains('&'),
1016            "override session should use '&', got: {text_override:?}"
1017        );
1018        assert_ne!(
1019            text_base, text_override,
1020            "sessions with different overrides should produce different output"
1021        );
1022    }
1023
1024    // --- integral_name_memory wiring ---
1025
1026    /// Build a style with integral-name memory configured (scope=Document,
1027    /// subsequent_form=Short) and an integral sub-template rendering Long names.
1028    fn integral_name_style() -> Style {
1029        use citum_schema::options::{
1030            IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1031        };
1032        Style {
1033            info: StyleInfo {
1034                title: Some("Integral Name Memory Session Test".to_string()),
1035                id: Some("integral-name-memory-session-test".into()),
1036                ..Default::default()
1037            },
1038            options: Some(Config {
1039                processing: Some(Processing::AuthorDate),
1040                integral_name_memory: Some(IntegralNameMemoryConfig {
1041                    scope: Some(IntegralNameScope::Document),
1042                    contexts: Some(IntegralNameContexts::BodyAndNotes),
1043                    subsequent_form: Some(SubsequentNameForm::Short),
1044                    ..Default::default()
1045                }),
1046                ..Default::default()
1047            }),
1048            citation: Some(CitationSpec {
1049                integral: Some(Box::new(CitationSpec {
1050                    template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1051                        contributor: ContributorRole::Author.into(),
1052                        form: ContributorForm::Long,
1053                        rendering: Rendering::default(),
1054                        ..Default::default()
1055                    })]),
1056                    ..Default::default()
1057                })),
1058                template: Some(vec![
1059                    TemplateComponent::Contributor(TemplateContributor {
1060                        contributor: ContributorRole::Author.into(),
1061                        form: ContributorForm::Short,
1062                        rendering: Rendering::default(),
1063                        ..Default::default()
1064                    }),
1065                    TemplateComponent::Date(TemplateDate {
1066                        date: TemplateDateVariable::Issued,
1067                        form: DateForm::Year,
1068                        rendering: Rendering {
1069                            prefix: Some(", ".into()),
1070                            ..Default::default()
1071                        },
1072                        ..Default::default()
1073                    }),
1074                ]),
1075                wrap: Some(WrapPunctuation::Parentheses.into()),
1076                ..Default::default()
1077            }),
1078            ..Default::default()
1079        }
1080    }
1081
1082    fn smith_refs() -> RefsInput {
1083        RefsInput::Yaml(
1084            r#"smith2020:
1085  class: monograph
1086  id: smith2020
1087  type: book
1088  title: Smith Book
1089  issued: "2020"
1090  author:
1091    - family: Smith
1092      given: John
1093"#
1094            .to_string(),
1095        )
1096    }
1097
1098    fn integral_citation(id: &str, ref_id: &str) -> CitationOccurrence {
1099        CitationOccurrence {
1100            id: id.to_string(),
1101            items: vec![crate::api::CitationOccurrenceItem {
1102                id: ref_id.to_string(),
1103                locator: None,
1104                prefix: None,
1105                suffix: None,
1106                integral_name_state: None,
1107                org_abbreviation_state: None,
1108            }],
1109            mode: Some(citum_schema::data::citation::CitationMode::Integral),
1110            note_number: None,
1111            suppress_author: None,
1112            grouped: None,
1113            prefix: None,
1114            suffix: None,
1115            sentence_start: None,
1116        }
1117    }
1118
1119    #[test]
1120    fn session_document_options_integral_name_memory_first_full_then_short() {
1121        use crate::processor::document::DocumentIntegralNameOverride;
1122
1123        let mut session = DocumentSession::new(
1124            integral_name_style(),
1125            StyleInput::Yaml(String::new()),
1126            None,
1127            OutputFormatKind::Plain,
1128            Some(DocumentOptions {
1129                integral_name_memory: Some(DocumentIntegralNameOverride {
1130                    enabled: Some(true),
1131                    ..Default::default()
1132                }),
1133                ..Default::default()
1134            }),
1135        );
1136        session
1137            .put_references(smith_refs())
1138            .expect("refs should resolve");
1139        let result = session
1140            .insert_citations_batch(vec![
1141                integral_citation("c1", "smith2020"),
1142                integral_citation("c2", "smith2020"),
1143            ])
1144            .expect("should render");
1145
1146        assert!(
1147            !result
1148                .warnings
1149                .iter()
1150                .any(|w| w.code == "integral_name_memory_not_applied"),
1151            "stale warning must not appear: {:?}",
1152            result.warnings
1153        );
1154
1155        let first = result
1156            .affected_citations
1157            .iter()
1158            .find(|c| c.id == "c1")
1159            .expect("c1 should be in result");
1160        let second = result
1161            .affected_citations
1162            .iter()
1163            .find(|c| c.id == "c2")
1164            .expect("c2 should be in result");
1165
1166        assert_eq!(
1167            first.text, "John Smith",
1168            "first integral cite should render full name form"
1169        );
1170        assert_eq!(
1171            second.text, "Smith",
1172            "second integral cite of same author should render short form"
1173        );
1174    }
1175
1176    #[test]
1177    fn session_document_options_integral_name_memory_disabled_keeps_full_form() {
1178        use crate::processor::document::DocumentIntegralNameOverride;
1179
1180        let mut session = DocumentSession::new(
1181            integral_name_style(),
1182            StyleInput::Yaml(String::new()),
1183            None,
1184            OutputFormatKind::Plain,
1185            Some(DocumentOptions {
1186                integral_name_memory: Some(DocumentIntegralNameOverride {
1187                    enabled: Some(false),
1188                    ..Default::default()
1189                }),
1190                ..Default::default()
1191            }),
1192        );
1193        session
1194            .put_references(smith_refs())
1195            .expect("refs should resolve");
1196        let result = session
1197            .insert_citations_batch(vec![
1198                integral_citation("c1", "smith2020"),
1199                integral_citation("c2", "smith2020"),
1200            ])
1201            .expect("should render");
1202
1203        let first = result
1204            .affected_citations
1205            .iter()
1206            .find(|c| c.id == "c1")
1207            .expect("c1 should be in result");
1208        let second = result
1209            .affected_citations
1210            .iter()
1211            .find(|c| c.id == "c2")
1212            .expect("c2 should be in result");
1213
1214        // Memory disabled — both occurrences render the natural Long form.
1215        assert_eq!(
1216            first.text, "John Smith",
1217            "first integral cite with disabled memory: {}",
1218            first.text
1219        );
1220        assert_eq!(
1221            second.text, "John Smith",
1222            "second integral cite should also be full when memory is disabled"
1223        );
1224    }
1225
1226    #[test]
1227    fn session_style_native_integral_name_memory_applied_without_document_override() {
1228        // Style has integral_name_memory in its own options; no document_options
1229        // override is supplied. The flat session API must still annotate.
1230        let mut session = DocumentSession::new(
1231            integral_name_style(),
1232            StyleInput::Yaml(String::new()),
1233            None,
1234            OutputFormatKind::Plain,
1235            None,
1236        );
1237        session
1238            .put_references(smith_refs())
1239            .expect("refs should resolve");
1240        let result = session
1241            .insert_citations_batch(vec![
1242                integral_citation("c1", "smith2020"),
1243                integral_citation("c2", "smith2020"),
1244            ])
1245            .expect("should render");
1246
1247        let first = result
1248            .affected_citations
1249            .iter()
1250            .find(|c| c.id == "c1")
1251            .expect("c1 should be in result");
1252        let second = result
1253            .affected_citations
1254            .iter()
1255            .find(|c| c.id == "c2")
1256            .expect("c2 should be in result");
1257
1258        assert_eq!(
1259            first.text, "John Smith",
1260            "first integral cite should render full name form"
1261        );
1262        assert_eq!(
1263            second.text, "Smith",
1264            "second integral cite should render short form from style-native config"
1265        );
1266    }
1267
1268    fn style_with_bibliography() -> Style {
1269        let mut s = style();
1270        s.bibliography = Some(BibliographySpec {
1271            template: Some(vec![TemplateComponent::Title(TemplateTitle {
1272                title: TitleType::Primary,
1273                ..Default::default()
1274            })]),
1275            ..Default::default()
1276        });
1277        s
1278    }
1279
1280    #[test]
1281    fn set_nocite_puts_ref_in_bibliography_not_in_formatted_citations() {
1282        // given: a session with smith2020 cited in-text and roe2022 nocite-only
1283        let mut session = DocumentSession::new(
1284            style_with_bibliography(),
1285            StyleInput::Yaml(String::new()),
1286            None,
1287            OutputFormatKind::Plain,
1288            None,
1289        );
1290        session.put_references(refs()).expect("refs should resolve");
1291        session
1292            .insert_citations_batch(vec![citation("c1", "smith2020")])
1293            .expect("citation insert should succeed");
1294
1295        // when: roe2022 is registered as nocite
1296        let result = session
1297            .set_nocite(vec!["roe2022".to_string()])
1298            .expect("set_nocite should succeed");
1299
1300        // then: roe2022 appears in bibliography entries but not in any formatted citation
1301        assert!(
1302            result
1303                .bibliography
1304                .entries
1305                .iter()
1306                .any(|e| e.id == "roe2022"),
1307            "nocite ref should appear in bibliography entries"
1308        );
1309        assert!(
1310            result
1311                .affected_citations
1312                .iter()
1313                .all(|c| c.text != "roe2022" && !c.ref_ids.iter().any(|r| r == "roe2022")),
1314            "nocite ref should not appear in any formatted citation"
1315        );
1316        // and: the uncited, non-nocite ref (doe2021) is absent from bibliography
1317        assert!(
1318            !result
1319                .bibliography
1320                .entries
1321                .iter()
1322                .any(|e| e.id == "doe2021"),
1323            "non-cited, non-nocite ref should not appear in bibliography"
1324        );
1325    }
1326
1327    #[test]
1328    fn put_references_with_malformed_input_returns_error() {
1329        // given: a fresh session
1330        let mut session = DocumentSession::new(
1331            style(),
1332            StyleInput::Yaml(String::new()),
1333            None,
1334            OutputFormatKind::Plain,
1335            None,
1336        );
1337
1338        // when: references are supplied as unparseable YAML
1339        let result = session.put_references(RefsInput::Yaml("not: [valid".to_string()));
1340
1341        // then: the parse error surfaces at put time, not on the next mutation
1342        assert!(
1343            matches!(result, Err(DocumentSessionError::Format(_))),
1344            "malformed refs input should error at put_references"
1345        );
1346    }
1347
1348    #[test]
1349    fn put_references_replaces_cached_reference_set() {
1350        // given: a session rendering smith2020 from the initial reference set
1351        let mut session = session();
1352        let first = session
1353            .insert_citations_batch(vec![citation("c1", "smith2020")])
1354            .expect("initial insert should render");
1355        let first_text = first.affected_citations[0].text.clone();
1356
1357        // when: put_references replaces the set with a different smith2020 year
1358        let mut replacement = Bibliography::new();
1359        replacement.insert(
1360            "smith2020".to_string(),
1361            reference("smith2020", "Smith", "2024"),
1362        );
1363        session
1364            .put_references(RefsInput::Json(
1365                serde_json::to_value(replacement).expect("replacement refs should serialize"),
1366            ))
1367            .expect("replacement refs should resolve");
1368        let second = session
1369            .insert_citations_batch(vec![citation("c1", "smith2020")])
1370            .expect("re-render should succeed");
1371
1372        // then: subsequent renders use the replaced (re-resolved) references —
1373        // identical output except the issued year
1374        assert_eq!(
1375            second.affected_citations[0].text,
1376            first_text.replace("2020", "2024"),
1377            "render after put_references should reflect the replaced reference set"
1378        );
1379        assert_ne!(second.affected_citations[0].text, first_text);
1380    }
1381}