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