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