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