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        position: Option<CitationInsertPosition>,
236    ) -> Result<PreviewCitationResult, DocumentSessionError> {
237        let mut citations = self.citations.clone();
238        let index = self.resolve_insert_index_in(&citations, position.as_ref())?;
239        let preview_id = "__citum_preview__".to_string();
240        citations.insert(
241            index,
242            CitationOccurrence {
243                id: preview_id.clone(),
244                items,
245                mode: None,
246                note_number: None,
247                suppress_author: None,
248                grouped: None,
249                prefix: None,
250                suffix: None,
251                sentence_start: None,
252            },
253        );
254        let rendered = self.render_citations(&citations)?;
255        let preview = rendered
256            .formatted_citations
257            .iter()
258            .find(|citation| citation.id == preview_id)
259            .map(|citation| citation.text.clone())
260            .unwrap_or_default();
261        Ok(PreviewCitationResult {
262            preview,
263            warnings: rendered.warnings,
264        })
265    }
266
267    /// Return the current formatted citations.
268    pub fn get_citations(&self) -> Vec<FormattedCitation> {
269        self.formatted_citations.clone()
270    }
271
272    /// Return the current bibliography, if a mutation has rendered one.
273    pub fn get_bibliography(&self) -> Option<FormattedBibliography> {
274        self.bibliography.clone()
275    }
276
277    fn commit_render(
278        &mut self,
279        old_citations: Vec<CitationOccurrence>,
280        old_formatted: Vec<FormattedCitation>,
281    ) -> Result<SessionMutationResult, DocumentSessionError> {
282        let rendered = self.render_citations(&self.citations)?;
283        let affected_citations =
284            diff_formatted_citations(&old_formatted, &rendered.formatted_citations);
285        let renumbering_occurred = renumbering_occurred(
286            &self.style,
287            &old_citations,
288            &self.citations,
289            &old_formatted,
290            &rendered.formatted_citations,
291        );
292        self.version += 1;
293        self.formatted_citations = rendered.formatted_citations;
294        self.bibliography = Some(rendered.bibliography.clone());
295        self.warnings = rendered.warnings.clone();
296        Ok(SessionMutationResult {
297            version: self.version,
298            affected_citations,
299            bibliography: rendered.bibliography,
300            renumbering_occurred,
301            warnings: rendered.warnings,
302        })
303    }
304
305    #[allow(
306        clippy::too_many_lines,
307        reason = "session rendering mirrors Tier 1 setup and format dispatch"
308    )]
309    fn render_citations(
310        &self,
311        citations: &[CitationOccurrence],
312    ) -> Result<SessionRenderResult, FormatDocumentError> {
313        let mut warnings = Vec::new();
314        if let Some(tag) = &self.locale
315            && !tag.is_empty()
316            && !tag.eq_ignore_ascii_case("en-us")
317        {
318            warnings.push(Warning {
319                level: WarningLevel::Warning,
320                code: "locale_fallback".to_string(),
321                citation_id: None,
322                ref_id: None,
323                message: format!(
324                    "Requested locale '{tag}' could not be loaded by the engine; falling back to en-US. Adapter-side locale resolution is not yet wired through."
325                ),
326            });
327        }
328
329        let bibliography = self
330            .refs
331            .clone()
332            .unwrap_or_else(|| RefsInput::Json(serde_json::json!({})))
333            .resolve_local()?;
334        let mut processor = Processor::new(self.style.clone(), bibliography);
335        warnings.extend(unknown_reference_class_warnings(&processor.bibliography));
336        warnings.extend(unknown_enum_warnings(&processor));
337
338        if let Some(opts) = &self.document_options {
339            if let Some(show_semantics) = opts.show_semantics {
340                processor.show_semantics = show_semantics;
341            }
342            if let Some(inject_ast) = opts.inject_ast_indices {
343                processor.set_inject_ast_indices(inject_ast);
344            }
345            if let Some(abbr_map) = opts.abbreviation_map.clone() {
346                processor.abbreviation_map = Some(abbr_map);
347            }
348            if opts.integral_name_memory.is_some() {
349                warnings.push(Warning {
350                    level: WarningLevel::Warning,
351                    code: "integral_name_memory_not_applied".to_string(),
352                    citation_id: None,
353                    ref_id: None,
354                    message: "document_options.integral_name_memory is accepted but not yet wired through the processor; tracked in csl26-ktq6.".to_string(),
355                });
356            }
357        }
358
359        let mut processor_citations: Vec<Citation> = Vec::new();
360        for occ in citations.iter().cloned() {
361            let mut citation: Citation = occ.into();
362            citation.items.retain(|item| {
363                if processor.bibliography.contains_key(&item.id) {
364                    true
365                } else {
366                    warnings.push(Warning {
367                        level: WarningLevel::Warning,
368                        code: "missing_ref".to_string(),
369                        citation_id: citation.id.clone(),
370                        ref_id: Some(item.id.clone()),
371                        message: format!("Reference '{}' not found in bibliography", item.id),
372                    });
373                    false
374                }
375            });
376            processor_citations.push(citation);
377        }
378
379        let formatted_citations = match self.output_format {
380            OutputFormatKind::Plain => {
381                format_by_kind::<PlainText>(&processor, &processor_citations)?
382            }
383            OutputFormatKind::Html => format_by_kind::<Html>(&processor, &processor_citations)?,
384            OutputFormatKind::Djot => format_by_kind::<Djot>(&processor, &processor_citations)?,
385            OutputFormatKind::Latex => format_by_kind::<Latex>(&processor, &processor_citations)?,
386            OutputFormatKind::Typst => format_by_kind::<Typst>(&processor, &processor_citations)?,
387            OutputFormatKind::Markdown => {
388                format_by_kind::<Markdown>(&processor, &processor_citations)?
389            }
390        };
391        let bibliography = match self.output_format {
392            OutputFormatKind::Plain => format_bibliography::<PlainText>(
393                &processor,
394                self.output_format,
395                self.document_options.as_ref(),
396            )?,
397            OutputFormatKind::Html => format_bibliography::<Html>(
398                &processor,
399                self.output_format,
400                self.document_options.as_ref(),
401            )?,
402            OutputFormatKind::Djot => format_bibliography::<Djot>(
403                &processor,
404                self.output_format,
405                self.document_options.as_ref(),
406            )?,
407            OutputFormatKind::Latex => format_bibliography::<Latex>(
408                &processor,
409                self.output_format,
410                self.document_options.as_ref(),
411            )?,
412            OutputFormatKind::Typst => format_bibliography::<Typst>(
413                &processor,
414                self.output_format,
415                self.document_options.as_ref(),
416            )?,
417            OutputFormatKind::Markdown => format_bibliography::<Markdown>(
418                &processor,
419                self.output_format,
420                self.document_options.as_ref(),
421            )?,
422        };
423
424        Ok(SessionRenderResult {
425            formatted_citations,
426            bibliography,
427            warnings,
428        })
429    }
430
431    fn citation_index(&self, citation_id: &str) -> Option<usize> {
432        self.citations
433            .iter()
434            .position(|citation| citation.id == citation_id)
435    }
436
437    fn resolve_insert_index(
438        &self,
439        position: Option<&CitationInsertPosition>,
440    ) -> Result<usize, DocumentSessionError> {
441        self.resolve_insert_index_in(&self.citations, position)
442    }
443
444    fn resolve_insert_index_in(
445        &self,
446        citations: &[CitationOccurrence],
447        position: Option<&CitationInsertPosition>,
448    ) -> Result<usize, DocumentSessionError> {
449        let Some(position) = position else {
450            return Ok(citations.len());
451        };
452        match (&position.after_citation_id, &position.before_citation_id) {
453            (None, None) => Ok(citations.len()),
454            (Some(after), None) => citations
455                .iter()
456                .position(|citation| citation.id == *after)
457                .map(|index| index + 1)
458                .ok_or_else(|| {
459                    DocumentSessionError::InvalidPosition(format!(
460                        "unknown after_citation_id '{after}'"
461                    ))
462                }),
463            (None, Some(before)) => citations
464                .iter()
465                .position(|citation| citation.id == *before)
466                .ok_or_else(|| {
467                    DocumentSessionError::InvalidPosition(format!(
468                        "unknown before_citation_id '{before}'"
469                    ))
470                }),
471            (Some(after), Some(before)) => {
472                let after_index = citations
473                    .iter()
474                    .position(|citation| citation.id == *after)
475                    .ok_or_else(|| {
476                        DocumentSessionError::InvalidPosition(format!(
477                            "unknown after_citation_id '{after}'"
478                        ))
479                    })?;
480                let before_index = citations
481                    .iter()
482                    .position(|citation| citation.id == *before)
483                    .ok_or_else(|| {
484                        DocumentSessionError::InvalidPosition(format!(
485                            "unknown before_citation_id '{before}'"
486                        ))
487                    })?;
488                if after_index + 1 == before_index {
489                    Ok(before_index)
490                } else {
491                    Err(DocumentSessionError::InvalidPosition(format!(
492                        "after_citation_id '{after}' and before_citation_id '{before}' are not adjacent"
493                    )))
494                }
495            }
496        }
497    }
498}
499
500#[derive(Debug)]
501struct SessionRenderResult {
502    formatted_citations: Vec<FormattedCitation>,
503    bibliography: FormattedBibliography,
504    warnings: Vec<Warning>,
505}
506
507fn diff_formatted_citations(
508    old: &[FormattedCitation],
509    new: &[FormattedCitation],
510) -> Vec<FormattedCitation> {
511    let old_by_id: HashMap<&str, &FormattedCitation> = old
512        .iter()
513        .map(|citation| (citation.id.as_str(), citation))
514        .collect();
515    new.iter()
516        .filter(|citation| {
517            old_by_id.get(citation.id.as_str()).is_none_or(|previous| {
518                previous.text != citation.text || previous.ref_ids != citation.ref_ids
519            })
520        })
521        .cloned()
522        .collect()
523}
524
525fn renumbering_occurred(
526    style: &Style,
527    old_citations: &[CitationOccurrence],
528    new_citations: &[CitationOccurrence],
529    old_formatted: &[FormattedCitation],
530    new_formatted: &[FormattedCitation],
531) -> bool {
532    if note_numbers_shifted(old_citations, new_citations) {
533        return true;
534    }
535    if !uses_numeric_labels(style) {
536        return false;
537    }
538    let old_by_id: HashMap<&str, &FormattedCitation> = old_formatted
539        .iter()
540        .map(|citation| (citation.id.as_str(), citation))
541        .collect();
542    let old_occurrences_by_id: HashMap<&str, &CitationOccurrence> = old_citations
543        .iter()
544        .map(|citation| (citation.id.as_str(), citation))
545        .collect();
546    let new_occurrences_by_id: HashMap<&str, &CitationOccurrence> = new_citations
547        .iter()
548        .map(|citation| (citation.id.as_str(), citation))
549        .collect();
550    new_formatted.iter().any(|citation| {
551        let Some(previous) = old_by_id.get(citation.id.as_str()) else {
552            return false;
553        };
554        if previous.text == citation.text {
555            return false;
556        }
557        let Some(old_occurrence) = old_occurrences_by_id.get(citation.id.as_str()) else {
558            return false;
559        };
560        let Some(new_occurrence) = new_occurrences_by_id.get(citation.id.as_str()) else {
561            return false;
562        };
563        *old_occurrence == *new_occurrence
564    })
565}
566
567fn note_numbers_shifted(
568    old_citations: &[CitationOccurrence],
569    new_citations: &[CitationOccurrence],
570) -> bool {
571    let old_by_id: HashMap<&str, Option<u32>> = old_citations
572        .iter()
573        .map(|citation| (citation.id.as_str(), citation.note_number))
574        .collect();
575    new_citations.iter().any(|citation| {
576        old_by_id
577            .get(citation.id.as_str())
578            .is_some_and(|old_note_number| *old_note_number != citation.note_number)
579    })
580}
581
582fn uses_numeric_labels(style: &Style) -> bool {
583    matches!(
584        style
585            .options
586            .as_ref()
587            .and_then(|options| options.processing.as_ref()),
588        Some(Processing::Numeric | Processing::Label(_))
589    )
590}
591
592#[cfg(test)]
593#[allow(
594    clippy::unwrap_used,
595    clippy::expect_used,
596    clippy::panic,
597    clippy::indexing_slicing,
598    reason = "test code uses assertions and panic"
599)]
600mod tests {
601    use super::*;
602    use crate::reference::Bibliography;
603    use crate::{
604        Config, Contributor, ContributorForm, ContributorList, ContributorRole, DateForm,
605        MultilingualString, Processing, Rendering, StructuredName, TemplateDateVariable,
606    };
607    use citum_schema::reference::{EdtfString, InputReference, Monograph, MonographType, Title};
608    use citum_schema::{
609        CitationSpec, StyleInfo, TemplateComponent, TemplateContributor, TemplateDate,
610        WrapPunctuation,
611    };
612
613    fn style() -> Style {
614        Style {
615            info: StyleInfo {
616                title: Some("Session Test Style".to_string()),
617                id: Some("session-test".into()),
618                ..Default::default()
619            },
620            options: Some(Config {
621                processing: Some(Processing::AuthorDate),
622                ..Default::default()
623            }),
624            citation: Some(CitationSpec {
625                template: Some(vec![
626                    TemplateComponent::Contributor(TemplateContributor {
627                        contributor: ContributorRole::Author,
628                        form: ContributorForm::Short,
629                        rendering: Rendering::default(),
630                        ..Default::default()
631                    }),
632                    TemplateComponent::Date(TemplateDate {
633                        date: TemplateDateVariable::Issued,
634                        form: DateForm::Year,
635                        rendering: Rendering {
636                            prefix: Some(", ".to_string()),
637                            ..Default::default()
638                        },
639                        ..Default::default()
640                    }),
641                ]),
642                wrap: Some(WrapPunctuation::Parentheses.into()),
643                ..Default::default()
644            }),
645            ..Default::default()
646        }
647    }
648
649    fn numeric_style() -> Style {
650        Style {
651            info: StyleInfo {
652                title: Some("Numeric Session Test Style".to_string()),
653                id: Some("numeric-session-test".into()),
654                ..Default::default()
655            },
656            options: Some(Config {
657                processing: Some(Processing::Numeric),
658                ..Default::default()
659            }),
660            ..Default::default()
661        }
662    }
663
664    fn refs() -> RefsInput {
665        let mut refs = Bibliography::new();
666        refs.insert(
667            "smith2020".to_string(),
668            reference("smith2020", "Smith", "2020"),
669        );
670        refs.insert("doe2021".to_string(), reference("doe2021", "Doe", "2021"));
671        refs.insert("roe2022".to_string(), reference("roe2022", "Roe", "2022"));
672        RefsInput::Json(serde_json::to_value(refs).expect("refs should serialize"))
673    }
674
675    fn reference(id: &str, family: &str, issued: &str) -> InputReference {
676        InputReference::Monograph(Box::new(Monograph {
677            id: Some(id.into()),
678            r#type: MonographType::Book,
679            title: Some(Title::Single(format!("{family} Work"))),
680            author: Some(Contributor::ContributorList(ContributorList(vec![
681                Contributor::StructuredName(StructuredName {
682                    family: MultilingualString::Simple(family.to_string()),
683                    given: MultilingualString::Simple("Alex".to_string()),
684                    suffix: None,
685                    dropping_particle: None,
686                    non_dropping_particle: None,
687                }),
688            ]))),
689            issued: EdtfString(issued.to_string()),
690            ..Default::default()
691        }))
692    }
693
694    fn citation(citation_id: &str, ref_id: &str) -> CitationOccurrence {
695        CitationOccurrence {
696            id: citation_id.to_string(),
697            items: vec![CitationOccurrenceItem {
698                id: ref_id.to_string(),
699                locator: None,
700                prefix: None,
701                suffix: None,
702                integral_name_state: None,
703                org_abbreviation_state: None,
704            }],
705            mode: None,
706            note_number: None,
707            suppress_author: None,
708            grouped: None,
709            prefix: None,
710            suffix: None,
711            sentence_start: None,
712        }
713    }
714
715    fn formatted(citation_id: &str, text: &str) -> FormattedCitation {
716        FormattedCitation {
717            id: citation_id.to_string(),
718            text: text.to_string(),
719            ref_ids: vec!["smith2020".to_string()],
720        }
721    }
722
723    fn session() -> DocumentSession {
724        let mut session = DocumentSession::new(
725            style(),
726            StyleInput::Yaml(String::new()),
727            None,
728            OutputFormatKind::Plain,
729            None,
730        );
731        session.put_references(refs());
732        session
733    }
734
735    #[test]
736    fn session_batch_insert_returns_complete_changed_set() {
737        let mut session = session();
738        let result = session
739            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
740            .expect("batch insert should render");
741
742        assert_eq!(result.version, 1);
743        assert_eq!(result.affected_citations.len(), 2);
744        assert_eq!(session.get_citations().len(), 2);
745        assert!(!result.renumbering_occurred);
746    }
747
748    #[test]
749    fn author_date_insert_does_not_report_renumbering() {
750        let mut session = session();
751        session
752            .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
753            .expect("batch insert should render");
754        let result = session
755            .insert_citation(
756                citation("c0", "roe2022"),
757                Some(CitationInsertPosition {
758                    after_citation_id: None,
759                    before_citation_id: Some("c1".to_string()),
760                }),
761            )
762            .expect("insert should render");
763
764        assert!(!result.renumbering_occurred);
765        assert_eq!(
766            result
767                .affected_citations
768                .iter()
769                .map(|citation| citation.id.as_str())
770                .collect::<Vec<_>>(),
771            vec!["c0"]
772        );
773    }
774
775    #[test]
776    fn note_number_shift_reports_renumbering() {
777        let mut session = session();
778        let mut first = citation("c1", "smith2020");
779        first.note_number = Some(1);
780        session
781            .insert_citations_batch(vec![first])
782            .expect("batch insert should render");
783        let mut updated = citation("c1", "smith2020");
784        updated.note_number = Some(2);
785        let result = session
786            .update_citation("c1", updated, None)
787            .expect("update should render");
788
789        assert!(result.renumbering_occurred);
790    }
791
792    #[test]
793    fn numeric_own_payload_edit_does_not_report_renumbering() {
794        let old = citation("c1", "smith2020");
795        let mut new = old.clone();
796        new.suffix = Some(", p. 12".to_string());
797
798        assert!(!renumbering_occurred(
799            &numeric_style(),
800            &[old],
801            &[new],
802            &[formatted("c1", "[1]")],
803            &[formatted("c1", "[1], p. 12")],
804        ));
805    }
806
807    #[test]
808    fn numeric_unchanged_existing_output_shift_reports_renumbering() {
809        let unchanged = citation("c1", "smith2020");
810
811        assert!(renumbering_occurred(
812            &numeric_style(),
813            std::slice::from_ref(&unchanged),
814            std::slice::from_ref(&unchanged),
815            &[formatted("c1", "[1]")],
816            &[formatted("c1", "[2]")],
817        ));
818    }
819
820    #[test]
821    fn preview_does_not_mutate_session() {
822        let mut session = session();
823        session
824            .insert_citations_batch(vec![citation("c1", "smith2020")])
825            .expect("batch insert should render");
826        let before_version = session.version();
827        let before_citations = session.get_citations();
828
829        let preview = session
830            .preview_citation(citation("preview", "doe2021").items, None)
831            .expect("preview should render");
832
833        assert!(!preview.preview.is_empty());
834        assert_eq!(session.version(), before_version);
835        assert_eq!(session.get_citations().len(), before_citations.len());
836    }
837}