1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31pub struct CitationInsertPosition {
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub after_citation_id: Option<String>,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub before_citation_id: Option<String>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OpenSessionResult {
43 pub session_id: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SessionMutationResult {
50 pub version: u64,
52 pub affected_citations: Vec<FormattedCitation>,
54 pub bibliography: FormattedBibliography,
56 pub renumbering_occurred: bool,
58 pub warnings: Vec<Warning>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct PreviewCitationResult {
65 pub preview: String,
67 pub warnings: Vec<Warning>,
69}
70
71#[derive(Debug)]
73pub enum DocumentSessionError {
74 CitationNotFound(String),
76 InvalidPosition(String),
78 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#[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 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 pub fn version(&self) -> u64 {
140 self.version
141 }
142
143 pub fn put_references(&mut self, refs: RefsInput) {
145 self.refs = Some(refs);
146 }
147
148 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 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 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 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 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 pub fn get_citations(&self) -> Vec<FormattedCitation> {
269 self.formatted_citations.clone()
270 }
271
272 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}