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