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