Skip to main content

citum_engine/api/
session.rs

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