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