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