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