Skip to main content

citum_schema_style/style/sections/
citation.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6//! Citation section specification.
7
8use std::collections::HashMap;
9
10#[cfg(feature = "schema")]
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14use crate::grouping;
15use crate::options::CitationOptions;
16use crate::template::{
17    LocalizedTemplateSpec, Template, TemplateReference, TemplateVariants, locale_matches,
18};
19
20/// Citation collapse behavior for multi-item citations.
21#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
22#[cfg_attr(feature = "schema", derive(JsonSchema))]
23#[serde(rename_all = "kebab-case")]
24pub enum CitationCollapse {
25    /// Collapse adjacent citation numbers into a numeric range such as `1–3`.
26    CitationNumber,
27}
28
29/// Text-case transform applied when a citation renders at note start.
30#[derive(Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)]
31#[cfg_attr(feature = "schema", derive(JsonSchema))]
32#[serde(rename_all = "kebab-case")]
33pub enum NoteStartTextCase {
34    /// Uppercase the first character of the rendered citation.
35    CapitalizeFirst,
36    /// Lowercase the rendered citation text.
37    Lowercase,
38}
39
40/// Citation specification.
41#[derive(Debug, Deserialize, Serialize, Clone, Default)]
42#[cfg_attr(feature = "schema", derive(JsonSchema))]
43#[serde(rename_all = "kebab-case")]
44pub struct CitationSpec {
45    /// Citation-specific option overrides merged over the style config.
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub options: Option<CitationOptions>,
48    /// Reference to an embedded template preset or external template.
49    ///
50    /// If both `template-ref` and `template` are present, `template` takes precedence.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub template_ref: Option<TemplateReference>,
53    /// Default template when no localized override is selected.
54    #[serde(skip_serializing_if = "Option::is_none", default)]
55    pub template: Option<Template>,
56    /// Locale-specific template overrides checked before the default template.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub locales: Option<Vec<LocalizedTemplateSpec>>,
59    /// Type-specific template overrides for citations. When present, replaces
60    /// the default citation template for references of the specified types.
61    /// Type-variant lookup happens after mode (integral/non-integral) resolution.
62    /// If both the main spec and the active mode sub-spec have a `type-variants`
63    /// entry for the same type, the mode-specific one wins.
64    #[serde(skip_serializing_if = "Option::is_none", rename = "type-variants")]
65    pub type_variants: Option<TemplateVariants>,
66    /// Wrap the entire citation in punctuation. Preferred over prefix/suffix.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub wrap: Option<crate::template::WrapConfig>,
69    /// Prefix for the citation (use only when `wrap` doesn't suffice, e.g., " (" or "[Ref ").
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub prefix: Option<String>,
72    /// Suffix for the citation (use only when `wrap` doesn't suffice).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub suffix: Option<String>,
75    /// Delimiter between components within a single citation item (e.g., ", " or " ").
76    /// Defaults to ", ".
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub delimiter: Option<String>,
79    /// Delimiter between multiple citation items (e.g., "; ").
80    /// Defaults to "; ".
81    #[serde(skip_serializing_if = "Option::is_none")]
82    #[serde(rename = "multi-cite-delimiter")]
83    pub multi_cite_delimiter: Option<String>,
84    /// Optional collapse behavior for adjacent multi-item citations.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub collapse: Option<CitationCollapse>,
87    /// Optional citation sorting specification.
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub sort: Option<grouping::GroupSortEntry>,
90    /// Configuration for integral (narrative) citations (e.g., "Smith (2020)").
91    /// Overrides fields from the main citation spec when mode is Integral.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub integral: Option<Box<CitationSpec>>,
94    /// Configuration for non-integral (parenthetical) citations (e.g., "(Smith, 2020)").
95    /// Overrides fields from the main citation spec when mode is NonIntegral.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub non_integral: Option<Box<CitationSpec>>,
98    /// Configuration for subsequent citations.
99    /// Overrides fields from the main citation spec when position is Subsequent.
100    /// Useful for short-form citations in note-based styles or author-date styles
101    /// that show abbreviated citations after the first mention.
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub subsequent: Option<Box<CitationSpec>>,
104    /// Configuration for ibid citations (ibid or ibid with locator).
105    /// Overrides fields from the main citation spec when position is Ibid or IbidWithLocator.
106    /// If present, takes precedence over `subsequent` for these positions.
107    /// Allows compact rendering like "ibid." or "ibid., p. 45".
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub ibid: Option<Box<CitationSpec>>,
110    /// Optional text-case transform for standalone note-start citation output.
111    ///
112    /// This is a style-owned rendering dimension layered on top of the
113    /// existing repeated-note state, not a new citation `Position`.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub note_start_text_case: Option<NoteStartTextCase>,
116    /// Custom user-defined fields for extensions.
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub custom: Option<HashMap<String, serde_json::Value>>,
119    /// Forward-compat: captures unknown keys when an older engine reads a
120    /// style produced by a newer schema. Empty by default; treated as a
121    /// SoftDegrade signal. See `docs/specs/FORWARD_COMPATIBILITY.md`.
122    #[serde(
123        flatten,
124        default,
125        skip_serializing_if = "std::collections::BTreeMap::is_empty"
126    )]
127    #[cfg_attr(feature = "schema", schemars(skip))]
128    pub unknown_fields: std::collections::BTreeMap<String, serde_yaml::Value>,
129}
130
131impl CitationSpec {
132    /// Resolve the effective template for this citation.
133    ///
134    /// Returns the explicit `template` if present, otherwise resolves `template-ref`.
135    /// Returns `None` if neither is specified.
136    pub fn resolve_template(&self) -> Option<Template> {
137        self.template.clone().or_else(|| {
138            self.template_ref
139                .as_ref()
140                .and_then(TemplateReference::citation_template)
141        })
142    }
143
144    /// Resolve the template for a language by checking localized overrides,
145    /// then the localized default, then the base template or preset.
146    pub fn resolve_template_for_language(&self, language: Option<&str>) -> Option<Template> {
147        if let Some(language) = language
148            && let Some(locales) = &self.locales
149            && let Some(matched) = locales.iter().find(|spec| {
150                spec.locale
151                    .as_ref()
152                    .is_some_and(|targets| locale_matches(targets, language))
153            })
154        {
155            return Some(matched.template.clone());
156        }
157
158        self.locales
159            .as_ref()
160            .and_then(|locales| {
161                locales
162                    .iter()
163                    .find(|spec| spec.default.unwrap_or(false))
164                    .map(|spec| spec.template.clone())
165            })
166            .or_else(|| self.resolve_template())
167    }
168
169    /// Resolve the template for a given reference type and language.
170    ///
171    /// First checks `type_variants` for an entry matching `ref_type`.
172    /// Falls back to `resolve_template_for_language` if no type-specific
173    /// template is found.
174    pub fn resolve_template_for_type(
175        &self,
176        ref_type: &str,
177        language: Option<&str>,
178    ) -> Option<Template> {
179        if let Some(type_variants) = &self.type_variants {
180            for (selector, variant) in type_variants {
181                if selector.matches(ref_type) {
182                    return variant.clone().into_template();
183                }
184            }
185        }
186        self.resolve_template_for_language(language)
187    }
188
189    /// Resolve the effective spec for a given citation mode.
190    ///
191    /// If a mode-specific spec exists (e.g., `integral`), it merges with and overrides
192    /// the base spec.
193    pub fn resolve_for_mode(
194        &self,
195        mode: &crate::citation::CitationMode,
196    ) -> std::borrow::Cow<'_, CitationSpec> {
197        use crate::citation::CitationMode;
198        let mode_spec = match mode {
199            CitationMode::Integral => self.integral.as_ref(),
200            CitationMode::NonIntegral => self.non_integral.as_ref(),
201        };
202
203        match mode_spec {
204            Some(spec) => {
205                // Merge logic: mode specific > base
206                let mut merged = self.clone();
207                // We don't want to recurse infinitely or keep the mode specs in the merged result
208                merged.integral = None;
209                merged.non_integral = None;
210
211                if spec.options.is_some() {
212                    merged.options = spec.options.clone();
213                }
214                if spec.template_ref.is_some() {
215                    merged.template_ref = spec.template_ref.clone();
216                }
217                if spec.template.is_some() {
218                    merged.template = spec.template.clone();
219                }
220                if spec.locales.is_some() {
221                    merged.locales = spec.locales.clone();
222                }
223                if spec.type_variants.is_some() {
224                    merged.type_variants = spec.type_variants.clone();
225                }
226                if spec.wrap.is_some() {
227                    merged.wrap = spec.wrap.clone();
228                }
229                if spec.prefix.is_some() {
230                    merged.prefix = spec.prefix.clone();
231                }
232                if spec.suffix.is_some() {
233                    merged.suffix = spec.suffix.clone();
234                }
235                if spec.delimiter.is_some() {
236                    merged.delimiter = spec.delimiter.clone();
237                }
238                if spec.multi_cite_delimiter.is_some() {
239                    merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
240                }
241                if spec.collapse.is_some() {
242                    merged.collapse = spec.collapse.clone();
243                }
244                if spec.sort.is_some() {
245                    merged.sort = spec.sort.clone();
246                }
247                if spec.note_start_text_case.is_some() {
248                    merged.note_start_text_case = spec.note_start_text_case;
249                }
250
251                std::borrow::Cow::Owned(merged)
252            }
253            None => std::borrow::Cow::Borrowed(self),
254        }
255    }
256
257    /// Resolve the effective spec for a given citation position.
258    ///
259    /// If a position-specific spec exists (e.g., `ibid` for Ibid position),
260    /// it merges with and overrides the base spec. Position resolution should
261    /// be applied before mode resolution to allow position-specific modes.
262    ///
263    /// Priority: ibid > subsequent > base
264    pub fn resolve_for_position(
265        &self,
266        position: Option<&crate::citation::Position>,
267    ) -> std::borrow::Cow<'_, CitationSpec> {
268        use crate::citation::Position;
269
270        let position_spec = match position {
271            Some(Position::Ibid | Position::IbidWithLocator) => {
272                self.ibid.as_ref().or(self.subsequent.as_ref())
273            }
274            Some(Position::Subsequent) => self.subsequent.as_ref(),
275            Some(Position::First) | None => None,
276        };
277
278        match position_spec {
279            Some(spec) => {
280                // Merge logic: position specific > base
281                let mut merged = self.clone();
282                // Don't recurse infinitely or keep position specs in merged result
283                merged.subsequent = None;
284                merged.ibid = None;
285
286                if spec.options.is_some() {
287                    merged.options = spec.options.clone();
288                }
289                if spec.template_ref.is_some() {
290                    merged.template_ref = spec.template_ref.clone();
291                }
292                if spec.template.is_some() {
293                    merged.template = spec.template.clone();
294                    // A position spec with its own template is a complete override —
295                    // clear inherited type_variants so the engine uses this template
296                    // directly rather than branching by ref type. If the position spec
297                    // wants type-specific rendering it must declare type_variants itself.
298                    if spec.type_variants.is_none() {
299                        merged.type_variants = None;
300                    }
301                }
302                if spec.locales.is_some() {
303                    merged.locales = spec.locales.clone();
304                }
305                if spec.type_variants.is_some() {
306                    merged.type_variants = spec.type_variants.clone();
307                }
308                if spec.wrap.is_some() {
309                    merged.wrap = spec.wrap.clone();
310                }
311                if spec.prefix.is_some() {
312                    merged.prefix = spec.prefix.clone();
313                }
314                if spec.suffix.is_some() {
315                    merged.suffix = spec.suffix.clone();
316                }
317                if spec.delimiter.is_some() {
318                    merged.delimiter = spec.delimiter.clone();
319                }
320                if spec.multi_cite_delimiter.is_some() {
321                    merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
322                }
323                if spec.collapse.is_some() {
324                    merged.collapse = spec.collapse.clone();
325                }
326                if spec.sort.is_some() {
327                    merged.sort = spec.sort.clone();
328                }
329                if spec.note_start_text_case.is_some() {
330                    merged.note_start_text_case = spec.note_start_text_case;
331                }
332
333                std::borrow::Cow::Owned(merged)
334            }
335            None => std::borrow::Cow::Borrowed(self),
336        }
337    }
338}