Skip to main content

citum_engine/processor/
note_context.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6//! Note-context normalization and citation position inference.
7//!
8//! These helpers prepare citation numbering for note styles by filling in
9//! missing note numbers and assigning positions such as `First`, `Subsequent`,
10//! and `Ibid` before citation rendering begins.
11
12use super::Processor;
13use crate::reference::{Citation, CitationItem};
14use citum_schema::citation::Position;
15
16/// Get a canonical locator string for ibid comparison.
17///
18/// Accounts for both single and compound locator forms.
19/// Returns `None` when no locator is present.
20fn effective_locator_string(item: &CitationItem) -> Option<String> {
21    item.locator
22        .as_ref()
23        .map(citum_schema::citation::CitationLocator::canonical_string)
24}
25
26impl Processor {
27    /// Detect and annotate citation positions.
28    ///
29    /// Analyzes citations in order and assigns positions based on whether an item
30    /// has been cited before:
31    /// - First: Item not cited before
32    /// - Subsequent: Item cited before but not immediately preceding
33    /// - Ibid: Same single item as immediately preceding citation with same locator context
34    /// - `IbidWithLocator`: Same single item as preceding, different locators
35    ///
36    /// Multi-item citations are never marked as Ibid (only First or Subsequent).
37    /// Only sets position if currently None (respects explicit caller values).
38    pub(crate) fn annotate_positions(&self, citations: &mut [Citation]) {
39        let mut seen_items: std::collections::HashMap<String, Option<String>> =
40            std::collections::HashMap::new();
41        let mut previous_items: Option<Vec<(String, Option<String>)>> = None;
42
43        for citation in citations.iter_mut() {
44            if citation.position.is_some() {
45                let current_items: Vec<(String, Option<String>)> = citation
46                    .items
47                    .iter()
48                    .map(|item| (item.id.clone(), effective_locator_string(item)))
49                    .collect();
50                previous_items = Some(current_items);
51                for item in &citation.items {
52                    seen_items.insert(item.id.clone(), effective_locator_string(item));
53                }
54                continue;
55            }
56
57            if citation.items.len() == 1 {
58                #[allow(clippy::indexing_slicing, reason = "citation.items.len() == 1")]
59                let current_id = &citation.items[0].id;
60                #[allow(clippy::indexing_slicing, reason = "citation.items.len() == 1")]
61                let current_locator = effective_locator_string(&citation.items[0]);
62
63                if let Some(previous) = previous_items.as_ref()
64                    && previous.len() == 1
65                    && let Some(prev_item) = previous.first()
66                    && prev_item.0 == *current_id
67                {
68                    let previous_locator = &prev_item.1;
69                    citation.position = Some(if previous_locator == &current_locator {
70                        Position::Ibid
71                    } else {
72                        Position::IbidWithLocator
73                    });
74                }
75
76                if citation.position.is_none() {
77                    citation.position = Some(if seen_items.contains_key(current_id) {
78                        Position::Subsequent
79                    } else {
80                        Position::First
81                    });
82                }
83
84                seen_items.insert(current_id.clone(), current_locator);
85            } else {
86                let all_seen = citation
87                    .items
88                    .iter()
89                    .all(|item| seen_items.contains_key(&item.id));
90
91                citation.position = Some(if all_seen {
92                    Position::Subsequent
93                } else {
94                    Position::First
95                });
96
97                for item in &citation.items {
98                    seen_items.insert(item.id.clone(), effective_locator_string(item));
99                }
100            }
101
102            previous_items = Some(
103                citation
104                    .items
105                    .iter()
106                    .map(|item| (item.id.clone(), effective_locator_string(item)))
107                    .collect(),
108            );
109        }
110    }
111
112    /// Normalize citation note context for note styles.
113    ///
114    /// Document/plugin layers should provide explicit `note_number` values.
115    /// When missing, this method assigns sequential note numbers in citation order.
116    pub fn normalize_note_context(&self, citations: &[Citation]) -> Vec<Citation> {
117        if !self.is_note_style() {
118            return citations.to_vec();
119        }
120
121        let mut next_note = 1_u32;
122        citations
123            .iter()
124            .cloned()
125            .map(|mut citation| {
126                if let Some(note_number) = citation.note_number {
127                    if note_number >= next_note {
128                        next_note = note_number.saturating_add(1);
129                    }
130                } else {
131                    citation.note_number = Some(next_note);
132                    next_note = next_note.saturating_add(1);
133                }
134                citation
135            })
136            .collect()
137    }
138}