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, term_locale_fallback_warnings, unknown_enum_warnings,
24 unknown_reference_class_warnings, 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 warnings.extend(term_locale_fallback_warnings(&processor));
371
372 if let Some(opts) = &self.document_options {
373 if let Some(new_proc) = processor
376 .processor_with_document_integral_name_override(opts.integral_name_memory.as_ref())
377 {
378 processor = new_proc;
379 }
380 if let Some(show_semantics) = opts.show_semantics {
381 processor.show_semantics = show_semantics;
382 }
383 if let Some(inject_ast) = opts.inject_ast_indices {
384 processor.set_inject_ast_indices(inject_ast);
385 }
386 if let Some(abbr_map) = opts.abbreviation_map.clone() {
387 processor.abbreviation_map = Some(abbr_map);
388 }
389 }
390
391 let mut processor_citations: Vec<Citation> = Vec::new();
392 for occ in citations.iter().cloned() {
393 let mut citation: Citation = occ.into();
394 citation.items.retain(|item| {
395 if processor.bibliography.contains_key(&item.id) {
396 true
397 } else {
398 warnings.push(Warning {
399 level: WarningLevel::Warning,
400 code: "missing_ref".to_string(),
401 citation_id: citation.id.clone(),
402 ref_id: Some(item.id.clone()),
403 message: format!("Reference '{}' not found in bibliography", item.id),
404 });
405 false
406 }
407 });
408 processor_citations.push(citation);
409 }
410
411 processor.annotate_flat_integral_name_states(&mut processor_citations);
415
416 let nocite_ids: Vec<String> = self
420 .nocite
421 .iter()
422 .filter_map(|id| {
423 if processor.bibliography.contains_key(id) {
424 Some(id.clone())
425 } else {
426 warnings.push(Warning {
427 level: WarningLevel::Warning,
428 code: "nocite_missing_ref".to_string(),
429 citation_id: None,
430 ref_id: Some(id.clone()),
431 message: format!("Nocite reference '{id}' not found in bibliography"),
432 });
433 None
434 }
435 })
436 .collect();
437
438 let mut run = processor.begin_run();
441
442 let formatted_citations = match self.output_format {
443 OutputFormatKind::Plain => {
444 format_by_kind::<PlainText>(&processor, &processor_citations, &mut run)?
445 }
446 OutputFormatKind::Html => {
447 format_by_kind::<Html>(&processor, &processor_citations, &mut run)?
448 }
449 OutputFormatKind::Djot => {
450 format_by_kind::<Djot>(&processor, &processor_citations, &mut run)?
451 }
452 OutputFormatKind::Latex => {
453 format_by_kind::<Latex>(&processor, &processor_citations, &mut run)?
454 }
455 OutputFormatKind::Typst => {
456 format_by_kind::<Typst>(&processor, &processor_citations, &mut run)?
457 }
458 OutputFormatKind::Markdown => {
459 format_by_kind::<Markdown>(&processor, &processor_citations, &mut run)?
460 }
461 };
462 processor.register_nocite_ids(nocite_ids, &mut run);
463 let run = run.finalize();
464 let bibliography = match self.output_format {
465 OutputFormatKind::Plain => format_bibliography::<PlainText>(
466 &processor,
467 self.output_format,
468 self.document_options.as_ref(),
469 &run,
470 )?,
471 OutputFormatKind::Html => format_bibliography::<Html>(
472 &processor,
473 self.output_format,
474 self.document_options.as_ref(),
475 &run,
476 )?,
477 OutputFormatKind::Djot => format_bibliography::<Djot>(
478 &processor,
479 self.output_format,
480 self.document_options.as_ref(),
481 &run,
482 )?,
483 OutputFormatKind::Latex => format_bibliography::<Latex>(
484 &processor,
485 self.output_format,
486 self.document_options.as_ref(),
487 &run,
488 )?,
489 OutputFormatKind::Typst => format_bibliography::<Typst>(
490 &processor,
491 self.output_format,
492 self.document_options.as_ref(),
493 &run,
494 )?,
495 OutputFormatKind::Markdown => format_bibliography::<Markdown>(
496 &processor,
497 self.output_format,
498 self.document_options.as_ref(),
499 &run,
500 )?,
501 };
502
503 Ok(SessionRenderResult {
504 formatted_citations,
505 bibliography,
506 warnings,
507 })
508 }
509
510 fn citation_index(&self, citation_id: &str) -> Option<usize> {
511 self.citations
512 .iter()
513 .position(|citation| citation.id == citation_id)
514 }
515
516 fn resolve_insert_index(
517 &self,
518 position: Option<&CitationInsertPosition>,
519 ) -> Result<usize, DocumentSessionError> {
520 self.resolve_insert_index_in(&self.citations, position)
521 }
522
523 fn resolve_insert_index_in(
524 &self,
525 citations: &[CitationOccurrence],
526 position: Option<&CitationInsertPosition>,
527 ) -> Result<usize, DocumentSessionError> {
528 let Some(position) = position else {
529 return Ok(citations.len());
530 };
531 match (&position.after_citation_id, &position.before_citation_id) {
532 (None, None) => Ok(citations.len()),
533 (Some(after), None) => citations
534 .iter()
535 .position(|citation| citation.id == *after)
536 .map(|index| index + 1)
537 .ok_or_else(|| {
538 DocumentSessionError::InvalidPosition(format!(
539 "unknown after_citation_id '{after}'"
540 ))
541 }),
542 (None, Some(before)) => citations
543 .iter()
544 .position(|citation| citation.id == *before)
545 .ok_or_else(|| {
546 DocumentSessionError::InvalidPosition(format!(
547 "unknown before_citation_id '{before}'"
548 ))
549 }),
550 (Some(after), Some(before)) => {
551 let after_index = citations
552 .iter()
553 .position(|citation| citation.id == *after)
554 .ok_or_else(|| {
555 DocumentSessionError::InvalidPosition(format!(
556 "unknown after_citation_id '{after}'"
557 ))
558 })?;
559 let before_index = citations
560 .iter()
561 .position(|citation| citation.id == *before)
562 .ok_or_else(|| {
563 DocumentSessionError::InvalidPosition(format!(
564 "unknown before_citation_id '{before}'"
565 ))
566 })?;
567 if after_index + 1 == before_index {
568 Ok(before_index)
569 } else {
570 Err(DocumentSessionError::InvalidPosition(format!(
571 "after_citation_id '{after}' and before_citation_id '{before}' are not adjacent"
572 )))
573 }
574 }
575 }
576 }
577}
578
579#[derive(Debug)]
580struct SessionRenderResult {
581 formatted_citations: Vec<FormattedCitation>,
582 bibliography: FormattedBibliography,
583 warnings: Vec<Warning>,
584}
585
586fn diff_formatted_citations(
587 old: &[FormattedCitation],
588 new: &[FormattedCitation],
589) -> Vec<FormattedCitation> {
590 let old_by_id: HashMap<&str, &FormattedCitation> = old
591 .iter()
592 .map(|citation| (citation.id.as_str(), citation))
593 .collect();
594 new.iter()
595 .filter(|citation| {
596 old_by_id.get(citation.id.as_str()).is_none_or(|previous| {
597 previous.text != citation.text || previous.ref_ids != citation.ref_ids
598 })
599 })
600 .cloned()
601 .collect()
602}
603
604fn renumbering_occurred(
605 style: &Style,
606 old_citations: &[CitationOccurrence],
607 new_citations: &[CitationOccurrence],
608 old_formatted: &[FormattedCitation],
609 new_formatted: &[FormattedCitation],
610) -> bool {
611 if note_numbers_shifted(old_citations, new_citations) {
612 return true;
613 }
614 if !uses_numeric_labels(style) {
615 return false;
616 }
617 let old_by_id: HashMap<&str, &FormattedCitation> = old_formatted
618 .iter()
619 .map(|citation| (citation.id.as_str(), citation))
620 .collect();
621 let old_occurrences_by_id: HashMap<&str, &CitationOccurrence> = old_citations
622 .iter()
623 .map(|citation| (citation.id.as_str(), citation))
624 .collect();
625 let new_occurrences_by_id: HashMap<&str, &CitationOccurrence> = new_citations
626 .iter()
627 .map(|citation| (citation.id.as_str(), citation))
628 .collect();
629 new_formatted.iter().any(|citation| {
630 let Some(previous) = old_by_id.get(citation.id.as_str()) else {
631 return false;
632 };
633 if previous.text == citation.text {
634 return false;
635 }
636 let Some(old_occurrence) = old_occurrences_by_id.get(citation.id.as_str()) else {
637 return false;
638 };
639 let Some(new_occurrence) = new_occurrences_by_id.get(citation.id.as_str()) else {
640 return false;
641 };
642 *old_occurrence == *new_occurrence
643 })
644}
645
646fn note_numbers_shifted(
647 old_citations: &[CitationOccurrence],
648 new_citations: &[CitationOccurrence],
649) -> bool {
650 let old_by_id: HashMap<&str, Option<u32>> = old_citations
651 .iter()
652 .map(|citation| (citation.id.as_str(), citation.note_number))
653 .collect();
654 new_citations.iter().any(|citation| {
655 old_by_id
656 .get(citation.id.as_str())
657 .is_some_and(|old_note_number| *old_note_number != citation.note_number)
658 })
659}
660
661fn uses_numeric_labels(style: &Style) -> bool {
662 matches!(
663 style
664 .options
665 .as_ref()
666 .and_then(|options| options.processing.as_ref()),
667 Some(Processing::Numeric | Processing::Label(_))
668 )
669}
670
671#[cfg(test)]
672#[allow(
673 clippy::unwrap_used,
674 clippy::expect_used,
675 clippy::panic,
676 clippy::indexing_slicing,
677 reason = "test code uses assertions and panic"
678)]
679mod tests {
680 use super::*;
681 use crate::reference::Bibliography;
682 use crate::{
683 Config, Contributor, ContributorForm, ContributorList, ContributorRole, DateForm,
684 MultilingualString, Processing, Rendering, StructuredName, TemplateDateVariable,
685 };
686 use citum_schema::reference::{DateValue, InputReference, Monograph, MonographType, Title};
687 use citum_schema::template::{TemplateTitle, TitleType};
688 use citum_schema::{
689 BibliographySpec, CitationSpec, StyleInfo, TemplateComponent, TemplateContributor,
690 TemplateDate, WrapPunctuation,
691 };
692
693 fn style() -> Style {
694 Style {
695 info: StyleInfo {
696 title: Some("Session Test Style".to_string()),
697 id: Some("session-test".into()),
698 ..Default::default()
699 },
700 options: Some(Config {
701 processing: Some(Processing::AuthorDate),
702 ..Default::default()
703 }),
704 citation: Some(CitationSpec {
705 template: Some(vec![
706 TemplateComponent::Contributor(TemplateContributor {
707 contributor: ContributorRole::Author.into(),
708 form: ContributorForm::Short,
709 rendering: Rendering::default(),
710 ..Default::default()
711 }),
712 TemplateComponent::Date(TemplateDate {
713 date: TemplateDateVariable::Issued,
714 form: DateForm::Year,
715 rendering: Rendering {
716 prefix: Some(", ".into()),
717 ..Default::default()
718 },
719 ..Default::default()
720 }),
721 ]),
722 wrap: Some(WrapPunctuation::Parentheses.into()),
723 ..Default::default()
724 }),
725 ..Default::default()
726 }
727 }
728
729 fn numeric_style() -> Style {
730 Style {
731 info: StyleInfo {
732 title: Some("Numeric Session Test Style".to_string()),
733 id: Some("numeric-session-test".into()),
734 ..Default::default()
735 },
736 options: Some(Config {
737 processing: Some(Processing::Numeric),
738 ..Default::default()
739 }),
740 ..Default::default()
741 }
742 }
743
744 fn refs() -> RefsInput {
745 let mut refs = Bibliography::new();
746 refs.insert(
747 "smith2020".to_string(),
748 reference("smith2020", "Smith", "2020"),
749 );
750 refs.insert("doe2021".to_string(), reference("doe2021", "Doe", "2021"));
751 refs.insert("roe2022".to_string(), reference("roe2022", "Roe", "2022"));
752 RefsInput::Json(serde_json::to_value(refs).expect("refs should serialize"))
753 }
754
755 fn reference(id: &str, family: &str, issued: &str) -> InputReference {
756 InputReference::Monograph(Box::new(Monograph {
757 id: Some(id.into()),
758 r#type: MonographType::Book,
759 title: Some(Title::Single(format!("{family} Work"))),
760 author: Some(Contributor::ContributorList(ContributorList(vec![
761 Contributor::StructuredName(StructuredName {
762 family: MultilingualString::Simple(family.to_string()),
763 given: MultilingualString::Simple("Alex".to_string()),
764 suffix: None,
765 dropping_particle: None,
766 non_dropping_particle: None,
767 }),
768 ]))),
769 issued: DateValue::new(issued.to_string()),
770 ..Default::default()
771 }))
772 }
773
774 fn citation(citation_id: &str, ref_id: &str) -> CitationOccurrence {
775 CitationOccurrence {
776 id: citation_id.to_string(),
777 items: vec![CitationOccurrenceItem {
778 id: ref_id.to_string(),
779 locator: None,
780 prefix: None,
781 suffix: None,
782 integral_name_state: None,
783 org_abbreviation_state: None,
784 }],
785 mode: None,
786 note_number: None,
787 suppress_author: None,
788 grouped: None,
789 prefix: None,
790 suffix: None,
791 sentence_start: None,
792 }
793 }
794
795 fn formatted(citation_id: &str, text: &str) -> FormattedCitation {
796 FormattedCitation {
797 id: citation_id.to_string(),
798 text: text.to_string(),
799 ref_ids: vec!["smith2020".to_string()],
800 }
801 }
802
803 fn session() -> DocumentSession {
804 let mut session = DocumentSession::new(
805 style(),
806 StyleInput::Yaml(String::new()),
807 None,
808 OutputFormatKind::Plain,
809 None,
810 );
811 session.put_references(refs()).expect("refs should resolve");
812 session
813 }
814
815 #[test]
816 fn session_batch_insert_returns_complete_changed_set() {
817 let mut session = session();
818 let result = session
819 .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
820 .expect("batch insert should render");
821
822 assert_eq!(result.version, 1);
823 assert_eq!(result.affected_citations.len(), 2);
824 assert_eq!(session.get_citations().len(), 2);
825 assert!(!result.renumbering_occurred);
826 }
827
828 #[test]
829 fn author_date_insert_does_not_report_renumbering() {
830 let mut session = session();
831 session
832 .insert_citations_batch(vec![citation("c1", "smith2020"), citation("c2", "doe2021")])
833 .expect("batch insert should render");
834 let result = session
835 .insert_citation(
836 citation("c0", "roe2022"),
837 Some(CitationInsertPosition {
838 after_citation_id: None,
839 before_citation_id: Some("c1".to_string()),
840 }),
841 )
842 .expect("insert should render");
843
844 assert!(!result.renumbering_occurred);
845 assert_eq!(
846 result
847 .affected_citations
848 .iter()
849 .map(|citation| citation.id.as_str())
850 .collect::<Vec<_>>(),
851 vec!["c0"]
852 );
853 }
854
855 #[test]
856 fn note_number_shift_reports_renumbering() {
857 let mut session = session();
858 let mut first = citation("c1", "smith2020");
859 first.note_number = Some(1);
860 session
861 .insert_citations_batch(vec![first])
862 .expect("batch insert should render");
863 let mut updated = citation("c1", "smith2020");
864 updated.note_number = Some(2);
865 let result = session
866 .update_citation("c1", updated, None)
867 .expect("update should render");
868
869 assert!(result.renumbering_occurred);
870 }
871
872 #[test]
873 fn numeric_own_payload_edit_does_not_report_renumbering() {
874 let old = citation("c1", "smith2020");
875 let mut new = old.clone();
876 new.suffix = Some(", p. 12".to_string());
877
878 assert!(!renumbering_occurred(
879 &numeric_style(),
880 &[old],
881 &[new],
882 &[formatted("c1", "[1]")],
883 &[formatted("c1", "[1], p. 12")],
884 ));
885 }
886
887 #[test]
888 fn numeric_unchanged_existing_output_shift_reports_renumbering() {
889 let unchanged = citation("c1", "smith2020");
890
891 assert!(renumbering_occurred(
892 &numeric_style(),
893 std::slice::from_ref(&unchanged),
894 std::slice::from_ref(&unchanged),
895 &[formatted("c1", "[1]")],
896 &[formatted("c1", "[2]")],
897 ));
898 }
899
900 #[test]
901 fn preview_does_not_mutate_session() {
902 use citum_schema::data::citation::CitationMode;
903
904 let mut session = DocumentSession::new(
905 integral_name_style(),
906 StyleInput::Yaml(String::new()),
907 None,
908 OutputFormatKind::Plain,
909 None,
910 );
911 session
912 .put_references(smith_refs())
913 .expect("refs should resolve");
914 session
915 .insert_citations_batch(vec![citation("c1", "smith2020")])
916 .expect("batch insert should render");
917 let before_version = session.version();
918 let before_citations = session.get_citations();
919 let preview_items = citation("preview", "smith2020").items;
920
921 let default_preview = session
922 .preview_citation(preview_items.clone(), None, None)
923 .expect("preview should render");
924 let integral_preview = session
925 .preview_citation(preview_items, Some(CitationMode::Integral), None)
926 .expect("integral preview should render");
927
928 assert!(!default_preview.preview.is_empty());
929 assert!(!integral_preview.preview.is_empty());
930 assert_ne!(default_preview.preview, integral_preview.preview);
931 assert_eq!(session.version(), before_version);
932 assert_eq!(session.get_citations().len(), before_citations.len());
933 }
934
935 #[test]
938 fn session_style_override_produces_divergent_output() {
939 use crate::api::apply_style_overrides;
940 use citum_schema::options::{AndOptions, ContributorConfig};
941
942 let mut base_style = style();
944 assert!(
945 base_style.options.is_some(),
946 "style() must return options: Some(...) for this test's contributor setup to take effect"
947 );
948 if let Some(opts) = base_style.options.as_mut() {
949 opts.contributors = Some(ContributorConfig {
950 and: Some(AndOptions::Text),
951 ..Default::default()
952 });
953 }
954
955 let two_author_refs = RefsInput::Yaml(
957 r#"duo2024:
958 class: monograph
959 id: duo2024
960 type: book
961 title: Duo Work
962 issued: "2024"
963 author:
964 - family: Smith
965 given: Alice
966 - family: Jones
967 given: Bob
968"#
969 .to_string(),
970 );
971
972 let mut session_base = DocumentSession::new(
974 base_style.clone(),
975 StyleInput::Yaml(String::new()),
976 None,
977 OutputFormatKind::Plain,
978 None,
979 );
980 session_base
981 .put_references(two_author_refs.clone())
982 .expect("refs should resolve");
983 let result_base = session_base
984 .insert_citations_batch(vec![citation("c1", "duo2024")])
985 .expect("base session should render");
986 let text_base = result_base.affected_citations[0].text.clone();
987
988 let mut style_overridden = base_style.clone();
990 apply_style_overrides(
991 &mut style_overridden,
992 "options:\n contributors:\n and: symbol\n",
993 )
994 .expect("override should parse");
995 let mut session_override = DocumentSession::new(
996 style_overridden,
997 StyleInput::Yaml(String::new()),
998 None,
999 OutputFormatKind::Plain,
1000 None,
1001 );
1002 session_override
1003 .put_references(two_author_refs)
1004 .expect("refs should resolve");
1005 let result_override = session_override
1006 .insert_citations_batch(vec![citation("c1", "duo2024")])
1007 .expect("override session should render");
1008 let text_override = result_override.affected_citations[0].text.clone();
1009
1010 assert!(
1011 text_base.contains("and"),
1012 "base session should use text 'and', got: {text_base:?}"
1013 );
1014 assert!(
1015 text_override.contains('&'),
1016 "override session should use '&', got: {text_override:?}"
1017 );
1018 assert_ne!(
1019 text_base, text_override,
1020 "sessions with different overrides should produce different output"
1021 );
1022 }
1023
1024 fn integral_name_style() -> Style {
1029 use citum_schema::options::{
1030 IntegralNameContexts, IntegralNameMemoryConfig, IntegralNameScope, SubsequentNameForm,
1031 };
1032 Style {
1033 info: StyleInfo {
1034 title: Some("Integral Name Memory Session Test".to_string()),
1035 id: Some("integral-name-memory-session-test".into()),
1036 ..Default::default()
1037 },
1038 options: Some(Config {
1039 processing: Some(Processing::AuthorDate),
1040 integral_name_memory: Some(IntegralNameMemoryConfig {
1041 scope: Some(IntegralNameScope::Document),
1042 contexts: Some(IntegralNameContexts::BodyAndNotes),
1043 subsequent_form: Some(SubsequentNameForm::Short),
1044 ..Default::default()
1045 }),
1046 ..Default::default()
1047 }),
1048 citation: Some(CitationSpec {
1049 integral: Some(Box::new(CitationSpec {
1050 template: Some(vec![TemplateComponent::Contributor(TemplateContributor {
1051 contributor: ContributorRole::Author.into(),
1052 form: ContributorForm::Long,
1053 rendering: Rendering::default(),
1054 ..Default::default()
1055 })]),
1056 ..Default::default()
1057 })),
1058 template: Some(vec![
1059 TemplateComponent::Contributor(TemplateContributor {
1060 contributor: ContributorRole::Author.into(),
1061 form: ContributorForm::Short,
1062 rendering: Rendering::default(),
1063 ..Default::default()
1064 }),
1065 TemplateComponent::Date(TemplateDate {
1066 date: TemplateDateVariable::Issued,
1067 form: DateForm::Year,
1068 rendering: Rendering {
1069 prefix: Some(", ".into()),
1070 ..Default::default()
1071 },
1072 ..Default::default()
1073 }),
1074 ]),
1075 wrap: Some(WrapPunctuation::Parentheses.into()),
1076 ..Default::default()
1077 }),
1078 ..Default::default()
1079 }
1080 }
1081
1082 fn smith_refs() -> RefsInput {
1083 RefsInput::Yaml(
1084 r#"smith2020:
1085 class: monograph
1086 id: smith2020
1087 type: book
1088 title: Smith Book
1089 issued: "2020"
1090 author:
1091 - family: Smith
1092 given: John
1093"#
1094 .to_string(),
1095 )
1096 }
1097
1098 fn integral_citation(id: &str, ref_id: &str) -> CitationOccurrence {
1099 CitationOccurrence {
1100 id: id.to_string(),
1101 items: vec![crate::api::CitationOccurrenceItem {
1102 id: ref_id.to_string(),
1103 locator: None,
1104 prefix: None,
1105 suffix: None,
1106 integral_name_state: None,
1107 org_abbreviation_state: None,
1108 }],
1109 mode: Some(citum_schema::data::citation::CitationMode::Integral),
1110 note_number: None,
1111 suppress_author: None,
1112 grouped: None,
1113 prefix: None,
1114 suffix: None,
1115 sentence_start: None,
1116 }
1117 }
1118
1119 #[test]
1120 fn session_document_options_integral_name_memory_first_full_then_short() {
1121 use crate::processor::document::DocumentIntegralNameOverride;
1122
1123 let mut session = DocumentSession::new(
1124 integral_name_style(),
1125 StyleInput::Yaml(String::new()),
1126 None,
1127 OutputFormatKind::Plain,
1128 Some(DocumentOptions {
1129 integral_name_memory: Some(DocumentIntegralNameOverride {
1130 enabled: Some(true),
1131 ..Default::default()
1132 }),
1133 ..Default::default()
1134 }),
1135 );
1136 session
1137 .put_references(smith_refs())
1138 .expect("refs should resolve");
1139 let result = session
1140 .insert_citations_batch(vec![
1141 integral_citation("c1", "smith2020"),
1142 integral_citation("c2", "smith2020"),
1143 ])
1144 .expect("should render");
1145
1146 assert!(
1147 !result
1148 .warnings
1149 .iter()
1150 .any(|w| w.code == "integral_name_memory_not_applied"),
1151 "stale warning must not appear: {:?}",
1152 result.warnings
1153 );
1154
1155 let first = result
1156 .affected_citations
1157 .iter()
1158 .find(|c| c.id == "c1")
1159 .expect("c1 should be in result");
1160 let second = result
1161 .affected_citations
1162 .iter()
1163 .find(|c| c.id == "c2")
1164 .expect("c2 should be in result");
1165
1166 assert_eq!(
1167 first.text, "John Smith",
1168 "first integral cite should render full name form"
1169 );
1170 assert_eq!(
1171 second.text, "Smith",
1172 "second integral cite of same author should render short form"
1173 );
1174 }
1175
1176 #[test]
1177 fn session_document_options_integral_name_memory_disabled_keeps_full_form() {
1178 use crate::processor::document::DocumentIntegralNameOverride;
1179
1180 let mut session = DocumentSession::new(
1181 integral_name_style(),
1182 StyleInput::Yaml(String::new()),
1183 None,
1184 OutputFormatKind::Plain,
1185 Some(DocumentOptions {
1186 integral_name_memory: Some(DocumentIntegralNameOverride {
1187 enabled: Some(false),
1188 ..Default::default()
1189 }),
1190 ..Default::default()
1191 }),
1192 );
1193 session
1194 .put_references(smith_refs())
1195 .expect("refs should resolve");
1196 let result = session
1197 .insert_citations_batch(vec![
1198 integral_citation("c1", "smith2020"),
1199 integral_citation("c2", "smith2020"),
1200 ])
1201 .expect("should render");
1202
1203 let first = result
1204 .affected_citations
1205 .iter()
1206 .find(|c| c.id == "c1")
1207 .expect("c1 should be in result");
1208 let second = result
1209 .affected_citations
1210 .iter()
1211 .find(|c| c.id == "c2")
1212 .expect("c2 should be in result");
1213
1214 assert_eq!(
1216 first.text, "John Smith",
1217 "first integral cite with disabled memory: {}",
1218 first.text
1219 );
1220 assert_eq!(
1221 second.text, "John Smith",
1222 "second integral cite should also be full when memory is disabled"
1223 );
1224 }
1225
1226 #[test]
1227 fn session_style_native_integral_name_memory_applied_without_document_override() {
1228 let mut session = DocumentSession::new(
1231 integral_name_style(),
1232 StyleInput::Yaml(String::new()),
1233 None,
1234 OutputFormatKind::Plain,
1235 None,
1236 );
1237 session
1238 .put_references(smith_refs())
1239 .expect("refs should resolve");
1240 let result = session
1241 .insert_citations_batch(vec![
1242 integral_citation("c1", "smith2020"),
1243 integral_citation("c2", "smith2020"),
1244 ])
1245 .expect("should render");
1246
1247 let first = result
1248 .affected_citations
1249 .iter()
1250 .find(|c| c.id == "c1")
1251 .expect("c1 should be in result");
1252 let second = result
1253 .affected_citations
1254 .iter()
1255 .find(|c| c.id == "c2")
1256 .expect("c2 should be in result");
1257
1258 assert_eq!(
1259 first.text, "John Smith",
1260 "first integral cite should render full name form"
1261 );
1262 assert_eq!(
1263 second.text, "Smith",
1264 "second integral cite should render short form from style-native config"
1265 );
1266 }
1267
1268 fn style_with_bibliography() -> Style {
1269 let mut s = style();
1270 s.bibliography = Some(BibliographySpec {
1271 template: Some(vec![TemplateComponent::Title(TemplateTitle {
1272 title: TitleType::Primary,
1273 ..Default::default()
1274 })]),
1275 ..Default::default()
1276 });
1277 s
1278 }
1279
1280 #[test]
1281 fn set_nocite_puts_ref_in_bibliography_not_in_formatted_citations() {
1282 let mut session = DocumentSession::new(
1284 style_with_bibliography(),
1285 StyleInput::Yaml(String::new()),
1286 None,
1287 OutputFormatKind::Plain,
1288 None,
1289 );
1290 session.put_references(refs()).expect("refs should resolve");
1291 session
1292 .insert_citations_batch(vec![citation("c1", "smith2020")])
1293 .expect("citation insert should succeed");
1294
1295 let result = session
1297 .set_nocite(vec!["roe2022".to_string()])
1298 .expect("set_nocite should succeed");
1299
1300 assert!(
1302 result
1303 .bibliography
1304 .entries
1305 .iter()
1306 .any(|e| e.id == "roe2022"),
1307 "nocite ref should appear in bibliography entries"
1308 );
1309 assert!(
1310 result
1311 .affected_citations
1312 .iter()
1313 .all(|c| c.text != "roe2022" && !c.ref_ids.iter().any(|r| r == "roe2022")),
1314 "nocite ref should not appear in any formatted citation"
1315 );
1316 assert!(
1318 !result
1319 .bibliography
1320 .entries
1321 .iter()
1322 .any(|e| e.id == "doe2021"),
1323 "non-cited, non-nocite ref should not appear in bibliography"
1324 );
1325 }
1326
1327 #[test]
1328 fn put_references_with_malformed_input_returns_error() {
1329 let mut session = DocumentSession::new(
1331 style(),
1332 StyleInput::Yaml(String::new()),
1333 None,
1334 OutputFormatKind::Plain,
1335 None,
1336 );
1337
1338 let result = session.put_references(RefsInput::Yaml("not: [valid".to_string()));
1340
1341 assert!(
1343 matches!(result, Err(DocumentSessionError::Format(_))),
1344 "malformed refs input should error at put_references"
1345 );
1346 }
1347
1348 #[test]
1349 fn put_references_replaces_cached_reference_set() {
1350 let mut session = session();
1352 let first = session
1353 .insert_citations_batch(vec![citation("c1", "smith2020")])
1354 .expect("initial insert should render");
1355 let first_text = first.affected_citations[0].text.clone();
1356
1357 let mut replacement = Bibliography::new();
1359 replacement.insert(
1360 "smith2020".to_string(),
1361 reference("smith2020", "Smith", "2024"),
1362 );
1363 session
1364 .put_references(RefsInput::Json(
1365 serde_json::to_value(replacement).expect("replacement refs should serialize"),
1366 ))
1367 .expect("replacement refs should resolve");
1368 let second = session
1369 .insert_citations_batch(vec![citation("c1", "smith2020")])
1370 .expect("re-render should succeed");
1371
1372 assert_eq!(
1375 second.affected_citations[0].text,
1376 first_text.replace("2020", "2024"),
1377 "render after put_references should reflect the replaced reference set"
1378 );
1379 assert_ne!(second.affected_citations[0].text, first_text);
1380 }
1381}