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