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 and Citum contributors
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 super::run_state::RunState;
14use crate::reference::{Citation, CitationItem};
15use citum_schema::citation::Position;
16
17/// Get a canonical locator string for ibid comparison.
18///
19/// Accounts for both single and compound locator forms.
20/// Returns `None` when no locator is present.
21fn effective_locator_string(item: &CitationItem) -> Option<String> {
22    item.locator
23        .as_ref()
24        .map(citum_schema::citation::CitationLocator::canonical_string)
25}
26
27impl Processor {
28    /// Detect and annotate citation positions.
29    ///
30    /// Analyzes citations in order and assigns positions based on whether an item
31    /// has been cited before:
32    /// - First: Item not cited before
33    /// - Subsequent: Item cited before but not immediately preceding
34    /// - Ibid: Same single item as immediately preceding citation with same locator context
35    /// - `IbidWithLocator`: Same single item as preceding, different locators
36    ///
37    /// Multi-item citations are never marked as Ibid (only First or Subsequent).
38    /// Only sets position if currently None (respects explicit caller values).
39    pub(crate) fn annotate_positions(&self, citations: &mut [Citation]) {
40        let mut seen_items: std::collections::HashMap<String, Option<String>> =
41            std::collections::HashMap::new();
42        let mut previous_items: Option<Vec<(String, Option<String>)>> = None;
43
44        for citation in citations.iter_mut() {
45            if citation.position.is_some() {
46                let current_items: Vec<(String, Option<String>)> = citation
47                    .items
48                    .iter()
49                    .map(|item| (item.id.clone(), effective_locator_string(item)))
50                    .collect();
51                previous_items = Some(current_items);
52                for item in &citation.items {
53                    seen_items.insert(item.id.clone(), effective_locator_string(item));
54                }
55                continue;
56            }
57
58            if citation.items.len() == 1 {
59                #[allow(clippy::indexing_slicing, reason = "citation.items.len() == 1")]
60                let current_id = &citation.items[0].id;
61                #[allow(clippy::indexing_slicing, reason = "citation.items.len() == 1")]
62                let current_locator = effective_locator_string(&citation.items[0]);
63
64                if let Some(previous) = previous_items.as_ref()
65                    && previous.len() == 1
66                    && let Some(prev_item) = previous.first()
67                    && prev_item.0 == *current_id
68                {
69                    let previous_locator = &prev_item.1;
70                    citation.position = Some(if previous_locator == &current_locator {
71                        Position::Ibid
72                    } else {
73                        Position::IbidWithLocator
74                    });
75                }
76
77                if citation.position.is_none() {
78                    citation.position = Some(if seen_items.contains_key(current_id) {
79                        Position::Subsequent
80                    } else {
81                        Position::First
82                    });
83                }
84
85                seen_items.insert(current_id.clone(), current_locator);
86            } else {
87                let all_seen = citation
88                    .items
89                    .iter()
90                    .all(|item| seen_items.contains_key(&item.id));
91
92                citation.position = Some(if all_seen {
93                    Position::Subsequent
94                } else {
95                    Position::First
96                });
97
98                for item in &citation.items {
99                    seen_items.insert(item.id.clone(), effective_locator_string(item));
100                }
101            }
102
103            previous_items = Some(
104                citation
105                    .items
106                    .iter()
107                    .map(|item| (item.id.clone(), effective_locator_string(item)))
108                    .collect(),
109            );
110        }
111    }
112
113    /// Normalize citation note context for note styles.
114    ///
115    /// Document/plugin layers should provide explicit `note_number` values.
116    /// When missing, this method assigns sequential note numbers in citation
117    /// order and records each reference's first-occurrence note number into
118    /// `run`.
119    pub fn normalize_note_context(
120        &self,
121        citations: &[Citation],
122        run: &mut RunState,
123    ) -> Vec<Citation> {
124        if !self.is_note_style() {
125            return citations.to_vec();
126        }
127
128        let mut next_note = 1_u32;
129        let normalized: Vec<Citation> = citations
130            .iter()
131            .cloned()
132            .map(|mut citation| {
133                if let Some(note_number) = citation.note_number {
134                    if note_number >= next_note {
135                        next_note = note_number.saturating_add(1);
136                    }
137                } else {
138                    citation.note_number = Some(next_note);
139                    next_note = next_note.saturating_add(1);
140                }
141                citation
142            })
143            .collect();
144
145        // Build first-occurrence note number map: id → note_number of first cite.
146        // Clear first so repeated calls (e.g. reprocessing after insertion/reordering)
147        // don't accumulate stale entries from prior runs.
148        let mut first_note = run
149            .first_note_by_id
150            .write()
151            .unwrap_or_else(std::sync::PoisonError::into_inner);
152        first_note.clear();
153        for citation in &normalized {
154            if let Some(note_number) = citation.note_number {
155                for item in &citation.items {
156                    first_note.entry(item.id.clone()).or_insert(note_number);
157                }
158            }
159        }
160        drop(first_note);
161
162        normalized
163    }
164}