Skip to main content

cdx_core/extensions/semantic/
citation.rs

1//! Citations and footnotes for academic documents.
2
3use serde::de::{self, MapAccess, Visitor};
4use serde::ser::SerializeMap;
5use serde::{Deserialize, Serialize};
6
7use crate::content::Block;
8
9/// An inline citation reference.
10///
11/// Supports both single and multi-reference citations (e.g., `[smith2023; jones2024]`).
12///
13/// # Backward Compatibility
14///
15/// Deserializes from both the old singular `"ref"` (string) format and the
16/// new `"refs"` (array) format.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Citation {
19    /// References to bibliography entry IDs.
20    pub refs: Vec<String>,
21
22    /// Page or location within the reference.
23    pub locator: Option<String>,
24
25    /// Locator type (page, chapter, section, etc.).
26    pub locator_type: Option<LocatorType>,
27
28    /// Text before the citation (e.g., "see").
29    pub prefix: Option<String>,
30
31    /// Text after the citation (e.g., "for details").
32    pub suffix: Option<String>,
33
34    /// Suppress author name in citation.
35    pub suppress_author: bool,
36}
37
38impl Citation {
39    /// Create a new citation with a single reference.
40    #[must_use]
41    pub fn new(reference: impl Into<String>) -> Self {
42        Self {
43            refs: vec![reference.into()],
44            locator: None,
45            locator_type: None,
46            prefix: None,
47            suffix: None,
48            suppress_author: false,
49        }
50    }
51
52    /// Create a citation with multiple references (e.g., `[smith2023; jones2024]`).
53    #[must_use]
54    pub fn multi(refs: Vec<String>) -> Self {
55        Self {
56            refs,
57            locator: None,
58            locator_type: None,
59            prefix: None,
60            suffix: None,
61            suppress_author: false,
62        }
63    }
64
65    /// Get the first reference, if any.
66    #[must_use]
67    pub fn first_ref(&self) -> Option<&str> {
68        self.refs.first().map(String::as_str)
69    }
70
71    /// Get all references.
72    #[must_use]
73    pub fn refs(&self) -> &[String] {
74        &self.refs
75    }
76
77    /// Set page locator.
78    #[must_use]
79    pub fn with_page(mut self, page: impl Into<String>) -> Self {
80        self.locator = Some(page.into());
81        self.locator_type = Some(LocatorType::Page);
82        self
83    }
84
85    /// Set locator with type.
86    #[must_use]
87    pub fn with_locator(mut self, locator: impl Into<String>, locator_type: LocatorType) -> Self {
88        self.locator = Some(locator.into());
89        self.locator_type = Some(locator_type);
90        self
91    }
92
93    /// Set prefix text.
94    #[must_use]
95    pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
96        self.prefix = Some(prefix.into());
97        self
98    }
99
100    /// Set suffix text.
101    #[must_use]
102    pub fn with_suffix(mut self, suffix: impl Into<String>) -> Self {
103        self.suffix = Some(suffix.into());
104        self
105    }
106
107    /// Suppress author name.
108    #[must_use]
109    pub const fn suppress_author(mut self) -> Self {
110        self.suppress_author = true;
111        self
112    }
113}
114
115impl Serialize for Citation {
116    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
117        let mut count = 1; // refs is always present
118        if self.locator.is_some() {
119            count += 1;
120        }
121        if self.locator_type.is_some() {
122            count += 1;
123        }
124        if self.prefix.is_some() {
125            count += 1;
126        }
127        if self.suffix.is_some() {
128            count += 1;
129        }
130        if self.suppress_author {
131            count += 1;
132        }
133
134        let mut map = serializer.serialize_map(Some(count))?;
135        map.serialize_entry("refs", &self.refs)?;
136        if let Some(ref locator) = self.locator {
137            map.serialize_entry("locator", locator)?;
138        }
139        if let Some(ref locator_type) = self.locator_type {
140            map.serialize_entry("locatorType", locator_type)?;
141        }
142        if let Some(ref prefix) = self.prefix {
143            map.serialize_entry("prefix", prefix)?;
144        }
145        if let Some(ref suffix) = self.suffix {
146            map.serialize_entry("suffix", suffix)?;
147        }
148        if self.suppress_author {
149            map.serialize_entry("suppressAuthor", &true)?;
150        }
151        map.end()
152    }
153}
154
155impl<'de> Deserialize<'de> for Citation {
156    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
157        struct CitationVisitor;
158
159        impl<'de> Visitor<'de> for CitationVisitor {
160            type Value = Citation;
161
162            fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163                formatter.write_str("a Citation object with 'refs' (array) or 'ref' (string)")
164            }
165
166            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Citation, A::Error> {
167                let mut refs: Option<Vec<String>> = None;
168                let mut locator: Option<String> = None;
169                let mut locator_type: Option<LocatorType> = None;
170                let mut prefix: Option<String> = None;
171                let mut suffix: Option<String> = None;
172                let mut suppress_author = false;
173
174                while let Some(key) = map.next_key::<String>()? {
175                    match key.as_str() {
176                        "refs" => {
177                            // Accept array of strings
178                            refs = Some(map.next_value::<Vec<String>>()?);
179                        }
180                        "ref" => {
181                            // Backward compat: singular string → wrap in vec
182                            let single: String = map.next_value()?;
183                            refs = Some(vec![single]);
184                        }
185                        "locator" => locator = Some(map.next_value()?),
186                        "locatorType" | "locator_type" => locator_type = Some(map.next_value()?),
187                        "prefix" => prefix = Some(map.next_value()?),
188                        "suffix" => suffix = Some(map.next_value()?),
189                        "suppressAuthor" | "suppress_author" => {
190                            suppress_author = map.next_value()?;
191                        }
192                        _ => {
193                            // Skip unknown fields for forward compatibility
194                            let _: serde_json::Value = map.next_value()?;
195                        }
196                    }
197                }
198
199                let refs = refs.ok_or_else(|| de::Error::missing_field("refs"))?;
200
201                Ok(Citation {
202                    refs,
203                    locator,
204                    locator_type,
205                    prefix,
206                    suffix,
207                    suppress_author,
208                })
209            }
210        }
211
212        deserializer.deserialize_map(CitationVisitor)
213    }
214}
215
216/// Type of locator within a reference.
217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum LocatorType {
220    /// Page number.
221    Page,
222    /// Chapter number.
223    Chapter,
224    /// Section number.
225    Section,
226    /// Paragraph number.
227    Paragraph,
228    /// Verse number.
229    Verse,
230    /// Line number.
231    Line,
232    /// Figure number.
233    Figure,
234    /// Table number.
235    Table,
236    /// Equation number.
237    Equation,
238    /// Timestamp (for media).
239    Timestamp,
240}
241
242/// A footnote with content blocks.
243///
244/// Per the spec, footnotes support either `content` (plain text) or `children`
245/// (rich content with blocks), but not both on the same footnote.
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
247#[serde(rename_all = "camelCase")]
248pub struct Footnote {
249    /// Sequential footnote number.
250    pub number: u32,
251
252    /// Optional unique identifier for cross-referencing with footnote marks.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub id: Option<String>,
255
256    /// Simple text content (for footnotes without complex formatting).
257    /// Mutually exclusive with `children`.
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub content: Option<String>,
260
261    /// Rich content blocks (paragraph blocks with formatting).
262    /// Mutually exclusive with `content`.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    pub children: Vec<Block>,
265}
266
267impl Footnote {
268    /// Create a new footnote with the given number.
269    #[must_use]
270    pub fn new(number: u32) -> Self {
271        Self {
272            number,
273            id: None,
274            content: None,
275            children: Vec::new(),
276        }
277    }
278
279    /// Set the unique identifier.
280    #[must_use]
281    pub fn with_id(mut self, id: impl Into<String>) -> Self {
282        self.id = Some(id.into());
283        self
284    }
285
286    /// Set the text content (simple footnotes without formatting).
287    ///
288    /// Note: This is mutually exclusive with `with_children`. If both are
289    /// set, implementations should prefer `children`.
290    #[must_use]
291    pub fn with_content(mut self, content: impl Into<String>) -> Self {
292        self.content = Some(content.into());
293        self
294    }
295
296    /// Set the rich content blocks (footnotes with formatting).
297    ///
298    /// Note: This is mutually exclusive with `with_content`. If both are
299    /// set, implementations should prefer `children`.
300    #[must_use]
301    pub fn with_children(mut self, children: Vec<Block>) -> Self {
302        self.children = children;
303        self
304    }
305
306    /// Check if this footnote has rich content (children).
307    #[must_use]
308    pub fn has_children(&self) -> bool {
309        !self.children.is_empty()
310    }
311
312    /// Check if this footnote has simple content.
313    #[must_use]
314    pub fn has_content(&self) -> bool {
315        self.content.is_some()
316    }
317}