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