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