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 and Citum contributors
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                match (&mut merged.options, &spec.options) {
212                    (Some(base), Some(mode)) => base.merge(mode),
213                    (None, Some(mode)) => merged.options = Some(mode.clone()),
214                    _ => {}
215                }
216                if spec.template_ref.is_some() {
217                    merged.template_ref = spec.template_ref.clone();
218                }
219                if spec.template.is_some() {
220                    merged.template = spec.template.clone();
221                }
222                if spec.locales.is_some() {
223                    merged.locales = spec.locales.clone();
224                }
225                if spec.type_variants.is_some() {
226                    merged.type_variants = spec.type_variants.clone();
227                }
228                if spec.wrap.is_some() {
229                    merged.wrap = spec.wrap.clone();
230                }
231                if spec.prefix.is_some() {
232                    merged.prefix = spec.prefix.clone();
233                }
234                if spec.suffix.is_some() {
235                    merged.suffix = spec.suffix.clone();
236                }
237                if spec.delimiter.is_some() {
238                    merged.delimiter = spec.delimiter.clone();
239                }
240                if spec.multi_cite_delimiter.is_some() {
241                    merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
242                }
243                if spec.collapse.is_some() {
244                    merged.collapse = spec.collapse.clone();
245                }
246                if spec.sort.is_some() {
247                    merged.sort = spec.sort.clone();
248                }
249                if spec.note_start_text_case.is_some() {
250                    merged.note_start_text_case = spec.note_start_text_case;
251                }
252
253                std::borrow::Cow::Owned(merged)
254            }
255            None => std::borrow::Cow::Borrowed(self),
256        }
257    }
258
259    /// Resolve the effective spec for a given citation position.
260    ///
261    /// If a position-specific spec exists (e.g., `ibid` for Ibid position),
262    /// it merges with and overrides the base spec. Position resolution should
263    /// be applied before mode resolution to allow position-specific modes.
264    ///
265    /// Priority: ibid > subsequent > base
266    pub fn resolve_for_position(
267        &self,
268        position: Option<&crate::citation::Position>,
269    ) -> std::borrow::Cow<'_, CitationSpec> {
270        use crate::citation::Position;
271
272        let position_spec = match position {
273            Some(Position::Ibid | Position::IbidWithLocator) => {
274                self.ibid.as_ref().or(self.subsequent.as_ref())
275            }
276            Some(Position::Subsequent) => self.subsequent.as_ref(),
277            Some(Position::First) | None => None,
278        };
279
280        match position_spec {
281            Some(spec) => {
282                // Merge logic: position specific > base
283                let mut merged = self.clone();
284                // Don't recurse infinitely or keep position specs in merged result
285                merged.subsequent = None;
286                merged.ibid = None;
287
288                match (&mut merged.options, &spec.options) {
289                    (Some(base), Some(mode)) => base.merge(mode),
290                    (None, Some(mode)) => merged.options = Some(mode.clone()),
291                    _ => {}
292                }
293                if spec.template_ref.is_some() {
294                    merged.template_ref = spec.template_ref.clone();
295                }
296                if spec.template.is_some() {
297                    merged.template = spec.template.clone();
298                    // A position spec with its own template is a complete override —
299                    // clear inherited type_variants so the engine uses this template
300                    // directly rather than branching by ref type. If the position spec
301                    // wants type-specific rendering it must declare type_variants itself.
302                    if spec.type_variants.is_none() {
303                        merged.type_variants = None;
304                    }
305                }
306                if spec.locales.is_some() {
307                    merged.locales = spec.locales.clone();
308                }
309                if spec.type_variants.is_some() {
310                    merged.type_variants = spec.type_variants.clone();
311                }
312                if spec.wrap.is_some() {
313                    merged.wrap = spec.wrap.clone();
314                }
315                if spec.prefix.is_some() {
316                    merged.prefix = spec.prefix.clone();
317                }
318                if spec.suffix.is_some() {
319                    merged.suffix = spec.suffix.clone();
320                }
321                if spec.delimiter.is_some() {
322                    merged.delimiter = spec.delimiter.clone();
323                }
324                if spec.multi_cite_delimiter.is_some() {
325                    merged.multi_cite_delimiter = spec.multi_cite_delimiter.clone();
326                }
327                if spec.collapse.is_some() {
328                    merged.collapse = spec.collapse.clone();
329                }
330                if spec.sort.is_some() {
331                    merged.sort = spec.sort.clone();
332                }
333                if spec.note_start_text_case.is_some() {
334                    merged.note_start_text_case = spec.note_start_text_case;
335                }
336
337                std::borrow::Cow::Owned(merged)
338            }
339            None => std::borrow::Cow::Borrowed(self),
340        }
341    }
342}