Skip to main content

citum_schema_style/options/
processing.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Processing mode and citation/bibliography rendering options.
7//!
8//! This module defines the processing modes (author-date, numeric, note, label, custom) that
9//! determine how citations and bibliographies are sorted, grouped, and disambiguated. Each
10//! mode provides default configurations for sorting and disambiguation strategies.
11
12/*
13SPDX-License-Identifier: MIT OR Apache-2.0
14SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
15*/
16
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::presets::SortPreset;
22
23const PROCESSING_STRING_VARIANTS: &[&str] = &[
24    "author-date",
25    "author-date-givenname",
26    "author-date-names",
27    "author-date-full",
28    "numeric",
29    "note",
30    "label",
31];
32
33/// Label style preset conventions.
34#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
35#[cfg_attr(feature = "schema", derive(JsonSchema))]
36#[serde(rename_all = "kebab-case")]
37#[non_exhaustive]
38pub enum LabelPreset {
39    /// biblatex alphabetic / BibTeX alpha.bst: up to 4 authors, "+" marker, 2-digit year.
40    #[default]
41    Alpha,
42    /// DIN 1505-2: up to 3 authors, no et-al marker, 2-digit year.
43    Din,
44    /// CSL/citeproc alphabetic labels used by American Mathematical Society styles.
45    Ams,
46}
47
48/// Resolved label generation parameters after applying preset defaults.
49///
50/// Stores the resolved (effective) parameters for label citation mode, combining
51/// preset defaults with any user-specified overrides from `LabelConfig`.
52#[derive(Debug, Clone)]
53pub struct LabelParams {
54    /// Number of characters from a single author's family name.
55    pub single_author_chars: u8,
56    /// Number of characters per author when multiple authors are present.
57    pub multi_author_chars: u8,
58    /// Maximum number of authors before truncation (et-al).
59    pub et_al_min: u8,
60    /// Suffix to append when authors are truncated (e.g., "+").
61    pub et_al_marker: String,
62    /// Number of names to show in et-al truncation.
63    pub et_al_names: u8,
64    /// Number of year digits to use (typically 2 or 4).
65    pub year_digits: u8,
66}
67
68/// Configuration for label citation mode.
69#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
70#[cfg_attr(feature = "schema", derive(JsonSchema))]
71#[serde(rename_all = "kebab-case")]
72pub struct LabelConfig {
73    /// Preset that determines default parameters.
74    #[serde(default)]
75    pub preset: LabelPreset,
76    /// Chars taken from single author's family name. Preset default: 3 (Alpha), 4 (Ams/Din).
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub single_author_chars: Option<u8>,
79    /// Chars per author family name when 2+ authors. Preset default: 1.
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub multi_author_chars: Option<u8>,
82    /// Max authors before truncation. Alpha default: 4, Ams default: 5, Din default: 3.
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub et_al_min: Option<u8>,
85    /// Suffix appended when truncated. Alpha default: "+", Ams/Din default: "".
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub et_al_marker: Option<String>,
88    /// Names shown when truncated (et-al). Alpha default: 3, Ams default: 4.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub et_al_names: Option<u8>,
91    /// Year digits: 2 or 4. Preset default: 2.
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub year_digits: Option<u8>,
94}
95
96impl LabelConfig {
97    /// Resolve effective parameters by merging preset defaults with overrides.
98    ///
99    /// This method applies the `LabelPreset` defaults first, then applies any user-specified
100    /// overrides from optional fields. For example, if the preset is `Alpha` but `single_author_chars`
101    /// is specified, the specified value takes precedence over the preset default of 3.
102    ///
103    /// # Returns
104    ///
105    /// A `LabelParams` struct with all parameters resolved to concrete values.
106    pub fn effective_params(&self) -> LabelParams {
107        let (
108            default_single_author_chars,
109            default_multi_author_chars,
110            default_et_al_min,
111            default_marker,
112            default_et_al_names,
113        ) = match self.preset {
114            LabelPreset::Alpha => (3u8, 1u8, 4u8, "+".to_string(), 3u8),
115            LabelPreset::Ams => (4u8, 1u8, 5u8, String::new(), 4u8),
116            LabelPreset::Din => (4u8, 1u8, 3u8, String::new(), 3u8),
117        };
118        LabelParams {
119            single_author_chars: self
120                .single_author_chars
121                .unwrap_or(default_single_author_chars),
122            multi_author_chars: self
123                .multi_author_chars
124                .unwrap_or(default_multi_author_chars),
125            et_al_min: self.et_al_min.unwrap_or(default_et_al_min),
126            et_al_marker: self.et_al_marker.clone().unwrap_or(default_marker),
127            et_al_names: self.et_al_names.unwrap_or(default_et_al_names),
128            year_digits: self.year_digits.unwrap_or(2),
129        }
130    }
131}
132
133/// Processing mode for citation/bibliography generation.
134///
135/// Determines how citations and bibliographies are sorted, grouped, and disambiguated.
136/// Can be specified as a simple string or with complex configuration maps:
137/// - A string: `"author-date"`, `"author-date-full"`, `"numeric"`, `"note"`, or `"label"`
138/// - A label config map: `{ label: { preset: din } }`
139/// - A custom config map: `{ sort: ..., group: ..., disambiguate: ... }`
140// `rename_all` is retained for `JsonSchema` derive (custom `Serialize` /
141// `Deserialize` impls below already use kebab-case names directly).
142#[derive(Debug, Default, PartialEq, Clone)]
143#[cfg_attr(feature = "schema", derive(JsonSchema))]
144#[cfg_attr(feature = "schema", schemars(rename_all = "kebab-case"))]
145#[non_exhaustive]
146pub enum Processing {
147    /// Author-date styles (e.g., APA, Chicago).
148    /// Default bibliography ordering: author, year, title; disambiguates by year suffix.
149    #[default]
150    AuthorDate,
151    /// Author-date styles that also add given names during disambiguation.
152    AuthorDateGivenname,
153    /// Author-date styles that also expand name lists during disambiguation.
154    AuthorDateNames,
155    /// Author-date styles that expand name lists and add given names during disambiguation.
156    AuthorDateFull,
157    /// Numeric styles (e.g., IEEE, Nature).
158    /// Do not imply a bibliography sort; citations are numbered in order of appearance.
159    Numeric,
160    /// Note styles (e.g., Chicago Notes-Bibliography).
161    /// With a bibliography default to author, title, year ordering.
162    Note,
163    /// Label styles (e.g., Alpha, DIN 1505-2).
164    /// Default bibliography ordering: author, year, title.
165    Label(LabelConfig),
166    /// Fully custom processing behavior.
167    /// Explicit `sort` configuration remains authoritative.
168    Custom(ProcessingCustom),
169}
170
171/// How citation-item sorting is resolved when `citation.sort` is absent.
172///
173/// Determines whether citation clusters can be reordered automatically or only
174/// when explicitly configured.
175#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(JsonSchema))]
177#[serde(rename_all = "kebab-case")]
178pub enum CitationSortPolicy {
179    /// Only an explicit `citation.sort` can reorder multi-cite clusters.
180    ExplicitOnly,
181}
182
183/// Named processing preset usable as the base of a custom delta.
184///
185/// Restricting `ProcessingCustom::base` to this enum makes nested custom
186/// configurations impossible by construction: a base is always one of the
187/// named presets, never another custom block.
188#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
189#[cfg_attr(feature = "schema", derive(JsonSchema))]
190#[serde(rename_all = "kebab-case")]
191#[non_exhaustive]
192pub enum ProcessingBase {
193    /// Delta base equivalent to `Processing::AuthorDate`.
194    AuthorDate,
195    /// Delta base equivalent to `Processing::AuthorDateGivenname`.
196    AuthorDateGivenname,
197    /// Delta base equivalent to `Processing::AuthorDateNames`.
198    AuthorDateNames,
199    /// Delta base equivalent to `Processing::AuthorDateFull`.
200    AuthorDateFull,
201    /// Delta base equivalent to `Processing::Numeric`.
202    Numeric,
203    /// Delta base equivalent to `Processing::Note`.
204    Note,
205    /// Delta base equivalent to `Processing::Label` with default label config.
206    Label,
207}
208
209impl ProcessingBase {
210    /// The named `Processing` variant this base stands for.
211    ///
212    /// `Label` maps to `Processing::Label(LabelConfig::default())`; all other
213    /// variants map to their unit counterparts.
214    pub fn processing(&self) -> Processing {
215        match self {
216            Self::AuthorDate => Processing::AuthorDate,
217            Self::AuthorDateGivenname => Processing::AuthorDateGivenname,
218            Self::AuthorDateNames => Processing::AuthorDateNames,
219            Self::AuthorDateFull => Processing::AuthorDateFull,
220            Self::Numeric => Processing::Numeric,
221            Self::Note => Processing::Note,
222            Self::Label => Processing::Label(LabelConfig::default()),
223        }
224    }
225}
226
227/// Custom processing configuration.
228///
229/// Allows explicit specification of sorting, grouping, and disambiguation rules.
230/// With a `base`, the block is a *delta*: present fields override the named
231/// preset's configuration wholesale and absent fields inherit from it (the same
232/// philosophy as style `extends:`). Without a `base`, present fields stand alone.
233#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "schema", derive(JsonSchema))]
235#[serde(rename_all = "kebab-case")]
236pub struct ProcessingCustom {
237    /// Named preset whose configuration seeds unset fields (optional).
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub base: Option<ProcessingBase>,
240    /// Bibliography sorting configuration (optional).
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub sort: Option<SortEntry>,
243    /// Bibliography grouping configuration (optional).
244    #[serde(skip_serializing_if = "Option::is_none")]
245    pub group: Option<Group>,
246    /// Disambiguation settings (optional).
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub disambiguate: Option<Disambiguation>,
249}
250
251impl ProcessingCustom {
252    /// Resolve the effective configuration by overlaying present fields onto
253    /// the base preset's config.
254    ///
255    /// Each present field (`sort`, `group`, `disambiguate`) replaces the base's
256    /// value wholesale; absent fields inherit the base's. Without a `base`,
257    /// returns the stored fields as-is. The result carries no `base` — it is
258    /// fully materialized.
259    #[must_use]
260    pub fn resolved(&self) -> ProcessingCustom {
261        let mut config = match self.base {
262            Some(base) => base.processing().config(),
263            None => ProcessingCustom::default(),
264        };
265        config.base = None;
266        if self.sort.is_some() {
267            config.sort = self.sort.clone();
268        }
269        if self.group.is_some() {
270            config.group = self.group.clone();
271        }
272        if self.disambiguate.is_some() {
273            config.disambiguate = self.disambiguate.clone();
274        }
275        config
276    }
277}
278
279/// Coarse citation regime family for cross-regime compatibility checks.
280///
281/// Groups the `Processing` variants into mutually-exclusive citation-surface
282/// families. Used by `merge_style_overlay` and `StyleLineage::apply_regime_guard`
283/// to detect when a child's regime differs from its parent's, so that
284/// regime-specific citation sub-specs (integral, non-integral) can be reset
285/// rather than silently inherited.
286///
287/// See `docs/specs/CITATION_REGIME.md`.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum RegimeFamily {
290    /// All `AuthorDate*` variants: primary key is `(Author, Year)`.
291    AuthorDate,
292    /// `Numeric`: primary key is citation-order number.
293    Numeric,
294    /// `Note`: citations render as footnotes or endnotes.
295    Note,
296    /// `Label`: citations render as trigraph labels.
297    Label,
298    /// `Custom`: fully user-defined; never triggers automatic resets.
299    Custom,
300}
301
302fn author_date_config(
303    names: bool,
304    add_givenname: bool,
305    givenname_rule: GivennameRule,
306) -> ProcessingCustom {
307    ProcessingCustom {
308        base: None,
309        sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
310        group: Some(Group {
311            template: vec![SortKey::Author, SortKey::Year],
312        }),
313        disambiguate: Some(Disambiguation {
314            names,
315            add_givenname,
316            givenname_rule,
317            year_suffix: true,
318        }),
319    }
320}
321
322impl Processing {
323    /// Default bibliography sort for the processing family, if any.
324    ///
325    /// Returns the standard bibliography sort order for the processing mode:
326    /// - `AuthorDate` / `Label`: author, year, title
327    /// - `Note`: author, title, year
328    /// - `Numeric`: None (no automatic sort)
329    /// - `Custom`: the base preset's default when a `base` is set and no
330    ///   explicit `sort` overrides it; otherwise None (an explicit custom sort
331    ///   resolves through `config()` instead, keeping the entry-ID tiebreak)
332    pub fn default_bibliography_sort(&self) -> Option<SortPreset> {
333        match self {
334            Processing::AuthorDate
335            | Processing::AuthorDateGivenname
336            | Processing::AuthorDateNames
337            | Processing::AuthorDateFull => Some(SortPreset::AuthorDateTitle),
338            Processing::Numeric => None,
339            Processing::Note => Some(SortPreset::AuthorTitleDate),
340            Processing::Label(_) => Some(SortPreset::AuthorDateTitle),
341            Processing::Custom(custom) => match (custom.base, custom.sort.as_ref()) {
342                (Some(base), None) => base.processing().default_bibliography_sort(),
343                _ => None,
344            },
345        }
346    }
347
348    /// Returns `true` for all author-date family variants.
349    ///
350    /// A `Custom` delta whose `base` is an author-date preset counts as
351    /// author-date: a delta on author-date is still an author-date style.
352    /// Centralizes the author-date family check so new variants don't require
353    /// updating scattered `matches!` blocks across the codebase.
354    pub fn is_author_date_family(&self) -> bool {
355        self.regime_family() == RegimeFamily::AuthorDate
356    }
357
358    /// Coarse citation regime family for cross-regime compatibility checks.
359    ///
360    /// Used during style inheritance to determine whether an inherited parent's
361    /// citation-mode sub-specs (integral, non-integral) belong to a different
362    /// regime and should be reset when the child supplies its own base template.
363    ///
364    /// A base-less `Custom` is its own family and never triggers automatic
365    /// sub-spec resets, preserving fully-custom authored styles. A `Custom`
366    /// delta with a `base` belongs to its base's family: a delta on
367    /// author-date is still an author-date style.
368    ///
369    /// See `docs/specs/CITATION_REGIME.md` for the full invariant.
370    pub fn regime_family(&self) -> RegimeFamily {
371        match self {
372            Self::AuthorDate
373            | Self::AuthorDateGivenname
374            | Self::AuthorDateNames
375            | Self::AuthorDateFull => RegimeFamily::AuthorDate,
376            Self::Numeric => RegimeFamily::Numeric,
377            Self::Note => RegimeFamily::Note,
378            Self::Label(_) => RegimeFamily::Label,
379            Self::Custom(custom) => match custom.base {
380                Some(base) => base.processing().regime_family(),
381                None => RegimeFamily::Custom,
382            },
383        }
384    }
385
386    /// Citation sorting remains explicit-only for all processing families.
387    ///
388    /// All processing modes use `ExplicitOnly`, meaning citation clusters are only
389    /// reordered when explicitly configured via `citation.sort`.
390    pub fn default_citation_sort_policy(&self) -> CitationSortPolicy {
391        CitationSortPolicy::ExplicitOnly
392    }
393
394    /// Get the effective bibliography/disambiguation configuration for this processing mode.
395    ///
396    /// Returns a `ProcessingCustom` struct with the resolved configuration combining
397    /// preset defaults and user overrides. For `Custom` mode, returns the user-provided config as-is.
398    pub fn config(&self) -> ProcessingCustom {
399        match self {
400            Processing::AuthorDate => author_date_config(false, false, GivennameRule::ByCite),
401            Processing::AuthorDateGivenname => {
402                author_date_config(false, true, GivennameRule::ByCite)
403            }
404            Processing::AuthorDateNames => author_date_config(true, false, GivennameRule::ByCite),
405            // `author-date-full` is the major author-date *guide* profile (APA §8.20,
406            // Chicago AD): it adds names + given names + year suffix, and uses the
407            // global `primary-name` rule so same-surname authors gain first-author
408            // initials in *every* in-text cite. (Citum's `by-cite` default is
409            // citation-local and would miss authors cited separately.) Initials vs full
410            // form follow each style's `initialize-with`/`name-form` contributor config.
411            Processing::AuthorDateFull => {
412                author_date_config(true, true, GivennameRule::PrimaryName)
413            }
414            Processing::Numeric => ProcessingCustom::default(),
415            Processing::Note => ProcessingCustom {
416                base: None,
417                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
418                group: None,
419                disambiguate: Some(Disambiguation {
420                    names: true,
421                    add_givenname: false,
422                    givenname_rule: GivennameRule::default(),
423                    year_suffix: false,
424                }),
425            },
426            Processing::Label(_) => ProcessingCustom {
427                base: None,
428                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
429                group: None,
430                disambiguate: Some(Disambiguation {
431                    names: false,
432                    add_givenname: false,
433                    givenname_rule: GivennameRule::default(),
434                    year_suffix: true,
435                }),
436            },
437            Processing::Custom(custom) => custom.resolved(),
438        }
439    }
440}
441
442impl Serialize for Processing {
443    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
444    where
445        S: serde::Serializer,
446    {
447        match self {
448            Processing::AuthorDate => serializer.serialize_str("author-date"),
449            Processing::AuthorDateGivenname => serializer.serialize_str("author-date-givenname"),
450            Processing::AuthorDateNames => serializer.serialize_str("author-date-names"),
451            Processing::AuthorDateFull => serializer.serialize_str("author-date-full"),
452            Processing::Numeric => serializer.serialize_str("numeric"),
453            Processing::Note => serializer.serialize_str("note"),
454            Processing::Label(config) => {
455                use serde::ser::SerializeMap;
456                let mut map = serializer.serialize_map(Some(1))?;
457                map.serialize_entry("label", config)?;
458                map.end()
459            }
460            // Emit `Custom` as a bare map so the YAML reads
461            // `processing:\n  sort: ...` instead of `processing: !custom`.
462            // The `visit_map` deserializer above already accepts this shape.
463            Processing::Custom(custom) => custom.serialize(serializer),
464        }
465    }
466}
467
468impl<'de> Deserialize<'de> for Processing {
469    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
470    where
471        D: serde::Deserializer<'de>,
472    {
473        use serde::de::{self, MapAccess, Visitor};
474
475        struct ProcessingVisitor;
476
477        impl<'de> Visitor<'de> for ProcessingVisitor {
478            type Value = Processing;
479
480            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
481                f.write_str("a processing mode string or map")
482            }
483
484            fn visit_str<E: de::Error>(self, v: &str) -> Result<Processing, E> {
485                match v {
486                    "author-date" => Ok(Processing::AuthorDate),
487                    "author-date-givenname" => Ok(Processing::AuthorDateGivenname),
488                    "author-date-names" => Ok(Processing::AuthorDateNames),
489                    "author-date-full" => Ok(Processing::AuthorDateFull),
490                    "numeric" => Ok(Processing::Numeric),
491                    "note" => Ok(Processing::Note),
492                    "label" => Ok(Processing::Label(LabelConfig::default())),
493                    other => Err(E::unknown_variant(other, PROCESSING_STRING_VARIANTS)),
494                }
495            }
496
497            fn visit_enum<A: de::EnumAccess<'de>>(self, data: A) -> Result<Processing, A::Error> {
498                use serde::de::VariantAccess;
499                let (variant, access) = data.variant::<String>()?;
500                match variant.as_str() {
501                    "custom" => {
502                        let custom: ProcessingCustom = access.newtype_variant()?;
503                        Ok(Processing::Custom(custom))
504                    }
505                    // `custom` is the only externally-tagged variant; named
506                    // string forms are handled by `visit_str` above.
507                    other => Err(de::Error::unknown_variant(other, &["custom"])),
508                }
509            }
510
511            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Processing, A::Error> {
512                let key: String = map
513                    .next_key()?
514                    .ok_or_else(|| de::Error::invalid_length(0, &"1"))?;
515                match key.as_str() {
516                    "label" => {
517                        let config: LabelConfig = map.next_value()?;
518                        Ok(Processing::Label(config))
519                    }
520                    "base" | "sort" | "group" | "disambiguate" => {
521                        // This is a custom processing config
522                        // We need to deserialize the whole map as ProcessingCustom
523                        // Unfortunately we can't easily re-parse from the middle of map access.
524                        // Instead, collect fields and build manually
525                        let mut base = None;
526                        let mut sort = None;
527                        let mut group = None;
528                        let mut disambiguate = None;
529
530                        // Values deserialize as `Option<_>` so an explicit
531                        // null (`base: ~`) reads as absent, matching derived
532                        // serde semantics and the JSON schema contract.
533
534                        // Handle the first key we already read
535                        match key.as_str() {
536                            "base" => base = map.next_value()?,
537                            "sort" => sort = map.next_value()?,
538                            "group" => group = map.next_value()?,
539                            "disambiguate" => disambiguate = map.next_value()?,
540                            _ => {
541                                return Err(de::Error::unknown_field(
542                                    &key,
543                                    &["base", "sort", "group", "disambiguate"],
544                                ));
545                            }
546                        }
547
548                        // Read remaining keys
549                        while let Some(k) = map.next_key::<String>()? {
550                            match k.as_str() {
551                                "base" => base = map.next_value()?,
552                                "sort" => sort = map.next_value()?,
553                                "group" => group = map.next_value()?,
554                                "disambiguate" => disambiguate = map.next_value()?,
555                                other => {
556                                    return Err(de::Error::unknown_field(
557                                        other,
558                                        &["base", "sort", "group", "disambiguate"],
559                                    ));
560                                }
561                            }
562                        }
563
564                        Ok(Processing::Custom(ProcessingCustom {
565                            base,
566                            sort,
567                            group,
568                            disambiguate,
569                        }))
570                    }
571                    other => Err(de::Error::unknown_field(
572                        other,
573                        &["label", "base", "sort", "group", "disambiguate"],
574                    )),
575                }
576            }
577        }
578
579        deserializer.deserialize_any(ProcessingVisitor)
580    }
581}
582
583/// Controls which author positions receive given-name expansion during disambiguation.
584///
585/// Maps to CSL's `givenname-disambiguation-rule` attribute on `<citation>`.
586/// The engine collapses these to two scopes: `PrimaryName` and
587/// `PrimaryNameWithInitials` expand only the first (primary) author; all other
588/// values expand all positions. Initials vs full form is always driven by the
589/// contributor config's `initialize-with` / `name-form` settings.
590#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
591#[cfg_attr(feature = "schema", derive(JsonSchema))]
592#[serde(rename_all = "kebab-case")]
593#[non_exhaustive]
594pub enum GivennameRule {
595    /// Disambiguate per-cite with a minimal subset of names (CSL 1.0.1 default).
596    /// Engine behaviour: expand all positions (per-cite minimal-subset deferred).
597    #[default]
598    ByCite,
599    /// Expand given names for all name positions.
600    AllNames,
601    /// Expand given names (initials form) for all name positions.
602    AllNamesWithInitials,
603    /// Expand given name of the first (primary) author only.
604    PrimaryName,
605    /// Expand given name (initials form) of the first (primary) author only.
606    PrimaryNameWithInitials,
607}
608
609/// Disambiguation settings.
610///
611/// Controls how ambiguous citations are disambiguated in the output.
612#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
613#[cfg_attr(feature = "schema", derive(JsonSchema))]
614#[serde(rename_all = "kebab-case")]
615pub struct Disambiguation {
616    /// Whether to attempt disambiguation by expanding author names.
617    pub names: bool,
618    /// Whether to add given names to disambiguate similarly-named authors.
619    #[serde(default)]
620    pub add_givenname: bool,
621    /// Which author positions receive given-name expansion.
622    #[serde(default)]
623    pub givenname_rule: GivennameRule,
624    /// Whether to append year suffixes (a, b, c, ...) for multiple works from the same author-year.
625    pub year_suffix: bool,
626}
627
628impl Default for Disambiguation {
629    fn default() -> Self {
630        Self {
631            names: true,
632            add_givenname: false,
633            givenname_rule: GivennameRule::default(),
634            year_suffix: false,
635        }
636    }
637}
638
639/// Sorting configuration.
640///
641/// Specifies how bibliography entries are ordered.
642#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
643#[cfg_attr(feature = "schema", derive(JsonSchema))]
644#[serde(rename_all = "kebab-case")]
645pub struct Sort {
646    /// Whether to shorten name lists for sorting the same as for display.
647    #[serde(default)]
648    pub shorten_names: bool,
649    /// Whether to apply the same name substitutions during sorting as during rendering.
650    #[serde(default)]
651    pub render_substitutions: bool,
652    /// Sort keys in order of application.
653    pub template: Vec<SortSpec>,
654}
655
656/// Sort configuration: either a preset name or explicit configuration.
657///
658/// Can be a preset name like `author-date-title` or a full `Sort` struct with explicit settings.
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660#[cfg_attr(feature = "schema", derive(JsonSchema))]
661#[serde(untagged)]
662pub enum SortEntry {
663    /// A named sort preset (e.g., `author-date-title`, `author-title-date`).
664    Preset(crate::presets::SortPreset),
665    /// Explicit sort configuration with custom keys and order.
666    Explicit(Sort),
667}
668
669impl SortEntry {
670    /// Resolve this entry to a concrete `Sort`.
671    ///
672    /// If this is a preset, returns the preset's sort definition. Otherwise returns the explicit sort as-is.
673    pub fn resolve(&self) -> Sort {
674        match self {
675            SortEntry::Preset(preset) => preset.sort(),
676            SortEntry::Explicit(sort) => sort.clone(),
677        }
678    }
679}
680
681impl Sort {
682    /// Convert this config-level sort to a [`crate::grouping::GroupSort`].
683    ///
684    /// Keys with no group-sort equivalent are skipped rather than mapped:
685    /// `CitationNumber` keeps registry order, since citation-number sorting
686    /// is registry order by definition (see the engine's
687    /// `citation_number_sort_not_supported` style-load warning). The match is
688    /// deliberately exhaustive so adding a `SortKey` variant forces an
689    /// explicit mapping decision here.
690    pub fn group_sort(&self) -> crate::grouping::GroupSort {
691        let template = self
692            .template
693            .iter()
694            .filter_map(|sort| {
695                let key = match sort.key {
696                    SortKey::Author => crate::grouping::SortKey::Author,
697                    SortKey::Year => crate::grouping::SortKey::Issued,
698                    SortKey::Title => crate::grouping::SortKey::Title,
699                    // No group-sort equivalent: citation-number sorting is
700                    // registry order by definition (see the engine's
701                    // `citation_number_sort_not_supported` warning).
702                    SortKey::CitationNumber => return None,
703                };
704                Some(crate::grouping::GroupSortKey {
705                    key,
706                    ascending: sort.ascending,
707                    order: None,
708                    sort_order: None,
709                })
710            })
711            .collect();
712
713        crate::grouping::GroupSort { template }
714    }
715}
716
717/// A single sort specification.
718///
719/// Defines one sort dimension with its key and direction.
720#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
721#[cfg_attr(feature = "schema", derive(JsonSchema))]
722#[serde(rename_all = "kebab-case")]
723pub struct SortSpec {
724    /// The field to sort by.
725    pub key: SortKey,
726    /// Whether to sort in ascending order (default: true).
727    #[serde(default = "default_ascending")]
728    pub ascending: bool,
729}
730
731fn default_ascending() -> bool {
732    true
733}
734
735/// Available sort keys.
736///
737/// Specifies what field to sort bibliography entries by.
738#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
739#[cfg_attr(feature = "schema", derive(JsonSchema))]
740#[serde(rename_all = "kebab-case")]
741#[non_exhaustive]
742pub enum SortKey {
743    /// Sort by the work's author(s).
744    #[default]
745    Author,
746    /// Sort by publication year.
747    Year,
748    /// Sort by the work's title.
749    Title,
750    /// Sort by citation order (typically used for numeric styles).
751    CitationNumber,
752}
753
754/// Grouping configuration for bibliography.
755///
756/// Specifies how bibliography entries should be grouped in the output.
757#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
758#[cfg_attr(feature = "schema", derive(JsonSchema))]
759#[serde(rename_all = "kebab-case")]
760pub struct Group {
761    /// Sort keys used to define group boundaries (e.g., [Author, Year]).
762    pub template: Vec<SortKey>,
763}
764
765#[cfg(test)]
766#[allow(
767    clippy::unwrap_used,
768    clippy::expect_used,
769    clippy::panic,
770    clippy::indexing_slicing,
771    clippy::todo,
772    clippy::unimplemented,
773    clippy::unreachable,
774    clippy::get_unwrap,
775    reason = "Panicking is acceptable and often desired in tests."
776)]
777mod tests {
778    use super::*;
779
780    /// Test that LabelConfig::effective_params() applies Alpha preset defaults.
781    #[test]
782    fn test_label_config_alpha_preset_defaults() {
783        let config = LabelConfig {
784            preset: LabelPreset::Alpha,
785            single_author_chars: None,
786            multi_author_chars: None,
787            et_al_min: None,
788            et_al_marker: None,
789            et_al_names: None,
790            year_digits: None,
791        };
792
793        let params = config.effective_params();
794        assert_eq!(params.single_author_chars, 3);
795        assert_eq!(params.multi_author_chars, 1);
796        assert_eq!(params.et_al_min, 4);
797        assert_eq!(params.et_al_marker, "+");
798        assert_eq!(params.et_al_names, 3);
799        assert_eq!(params.year_digits, 2);
800    }
801
802    /// Test that LabelConfig overrides take precedence over preset defaults.
803    #[test]
804    fn test_label_config_alpha_with_overrides() {
805        let config = LabelConfig {
806            preset: LabelPreset::Alpha,
807            single_author_chars: Some(5),
808            multi_author_chars: Some(2),
809            et_al_min: Some(5),
810            et_al_marker: Some("*".to_string()),
811            et_al_names: Some(4),
812            year_digits: Some(4),
813        };
814
815        let params = config.effective_params();
816        assert_eq!(params.single_author_chars, 5);
817        assert_eq!(params.multi_author_chars, 2);
818        assert_eq!(params.et_al_min, 5);
819        assert_eq!(params.et_al_marker, "*");
820        assert_eq!(params.et_al_names, 4);
821        assert_eq!(params.year_digits, 4);
822    }
823
824    /// Test that LabelConfig::effective_params() applies Din preset defaults.
825    #[test]
826    fn test_label_config_din_preset_defaults() {
827        let config = LabelConfig {
828            preset: LabelPreset::Din,
829            single_author_chars: None,
830            multi_author_chars: None,
831            et_al_min: None,
832            et_al_marker: None,
833            et_al_names: None,
834            year_digits: None,
835        };
836
837        let params = config.effective_params();
838        assert_eq!(params.single_author_chars, 4);
839        assert_eq!(params.multi_author_chars, 1);
840        assert_eq!(params.et_al_min, 3);
841        assert_eq!(params.et_al_marker, "");
842        assert_eq!(params.et_al_names, 3);
843        assert_eq!(params.year_digits, 2);
844    }
845
846    /// Test that LabelConfig::effective_params() applies AMS/CSL label defaults.
847    #[test]
848    fn test_label_config_ams_preset_defaults() {
849        let config = LabelConfig {
850            preset: LabelPreset::Ams,
851            single_author_chars: None,
852            multi_author_chars: None,
853            et_al_min: None,
854            et_al_marker: None,
855            et_al_names: None,
856            year_digits: None,
857        };
858
859        let params = config.effective_params();
860        assert_eq!(params.single_author_chars, 4);
861        assert_eq!(params.multi_author_chars, 1);
862        assert_eq!(params.et_al_min, 5);
863        assert_eq!(params.et_al_marker, "");
864        assert_eq!(params.et_al_names, 4);
865        assert_eq!(params.year_digits, 2);
866    }
867
868    /// Test that Processing::AuthorDate returns correct default sort.
869    #[test]
870    fn test_processing_author_date_default_bibliography_sort() {
871        let processing = Processing::AuthorDate;
872        let sort = processing.default_bibliography_sort();
873        assert_eq!(sort, Some(SortPreset::AuthorDateTitle));
874    }
875
876    /// Test that Processing::Numeric returns no default sort.
877    #[test]
878    fn test_processing_numeric_default_bibliography_sort() {
879        let processing = Processing::Numeric;
880        let sort = processing.default_bibliography_sort();
881        assert_eq!(sort, None);
882    }
883
884    /// Test that Processing::Note returns correct default sort.
885    #[test]
886    fn test_processing_note_default_bibliography_sort() {
887        let processing = Processing::Note;
888        let sort = processing.default_bibliography_sort();
889        assert_eq!(sort, Some(SortPreset::AuthorTitleDate));
890    }
891
892    /// Test that all Processing modes return ExplicitOnly citation sort policy.
893    #[test]
894    fn test_processing_citation_sort_policy() {
895        let modes = vec![
896            Processing::AuthorDate,
897            Processing::AuthorDateGivenname,
898            Processing::AuthorDateNames,
899            Processing::AuthorDateFull,
900            Processing::Numeric,
901            Processing::Note,
902            Processing::Label(LabelConfig::default()),
903            Processing::Custom(ProcessingCustom::default()),
904        ];
905
906        for mode in modes {
907            assert_eq!(
908                mode.default_citation_sort_policy(),
909                CitationSortPolicy::ExplicitOnly
910            );
911        }
912    }
913
914    /// Test that Processing::config() returns correct configuration for author-date variants.
915    #[test]
916    fn test_processing_author_date_variant_configs() {
917        let cases = [
918            (Processing::AuthorDate, false, false, GivennameRule::ByCite),
919            (
920                Processing::AuthorDateGivenname,
921                false,
922                true,
923                GivennameRule::ByCite,
924            ),
925            (
926                Processing::AuthorDateNames,
927                true,
928                false,
929                GivennameRule::ByCite,
930            ),
931            // Only `author-date-full` (the guide profile) uses the global primary-name rule.
932            (
933                Processing::AuthorDateFull,
934                true,
935                true,
936                GivennameRule::PrimaryName,
937            ),
938        ];
939
940        for (processing, names, add_givenname, expected_rule) in cases {
941            let config = processing.config();
942
943            assert_eq!(
944                config.sort,
945                Some(SortEntry::Preset(SortPreset::AuthorDateTitle))
946            );
947            assert_eq!(
948                config.group,
949                Some(Group {
950                    template: vec![SortKey::Author, SortKey::Year],
951                })
952            );
953
954            let disambig = config.disambiguate.unwrap();
955            assert_eq!(disambig.names, names);
956            assert_eq!(disambig.add_givenname, add_givenname);
957            assert_eq!(disambig.givenname_rule, expected_rule);
958            assert!(disambig.year_suffix);
959        }
960    }
961
962    /// Test that author-date processing variants round-trip through their public names.
963    #[test]
964    fn test_processing_author_date_variant_names() {
965        let cases = [
966            (Processing::AuthorDate, "author-date"),
967            (Processing::AuthorDateGivenname, "author-date-givenname"),
968            (Processing::AuthorDateNames, "author-date-names"),
969            (Processing::AuthorDateFull, "author-date-full"),
970        ];
971
972        for (processing, name) in cases {
973            let serialized = serde_yaml::to_string(&processing).unwrap();
974            assert_eq!(serialized.trim(), name);
975
976            let deserialized: Processing = serde_yaml::from_str(name).unwrap();
977            assert_eq!(deserialized, processing);
978        }
979    }
980
981    /// Test that a custom map with `base:` and an explicit sort round-trips
982    /// through YAML preserving the sparse delta shape.
983    #[test]
984    fn test_processing_custom_base_round_trip() {
985        // given: a custom delta on author-date with only an explicit sort
986        let processing = Processing::Custom(ProcessingCustom {
987            base: Some(ProcessingBase::AuthorDate),
988            sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
989            group: None,
990            disambiguate: None,
991        });
992
993        // when: serialized to YAML and parsed back
994        let yaml = serde_yaml::to_string(&processing).unwrap();
995        let parsed: Processing = serde_yaml::from_str(&yaml).unwrap();
996
997        // then: the YAML stays sparse (base + sort only) and round-trips
998        assert_eq!(yaml.trim(), "base: author-date\nsort: author-title-date");
999        assert_eq!(parsed, processing);
1000    }
1001
1002    /// Test that a map with only `base:` parses and resolves to the bare
1003    /// preset's config.
1004    #[test]
1005    fn test_processing_custom_base_only_resolves_to_preset_config() {
1006        // given: YAML declaring only a base
1007        let parsed: Processing = serde_yaml::from_str("base: author-date-full").unwrap();
1008
1009        // then: it parses as Custom with the base and no overrides
1010        assert_eq!(
1011            parsed,
1012            Processing::Custom(ProcessingCustom {
1013                base: Some(ProcessingBase::AuthorDateFull),
1014                sort: None,
1015                group: None,
1016                disambiguate: None,
1017            })
1018        );
1019
1020        // and: config() matches the bare preset's config
1021        assert_eq!(parsed.config(), Processing::AuthorDateFull.config());
1022    }
1023
1024    /// Test that resolved() overlays present fields wholesale and inherits
1025    /// absent fields from the base preset.
1026    #[test]
1027    fn test_processing_custom_resolved_overlay_semantics() {
1028        // given: a delta on author-date overriding only the sort
1029        let custom = ProcessingCustom {
1030            base: Some(ProcessingBase::AuthorDate),
1031            sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
1032            group: None,
1033            disambiguate: None,
1034        };
1035
1036        // when: resolved against the base
1037        let resolved = custom.resolved();
1038
1039        // then: the explicit sort wins; group/disambiguate inherit; no base remains
1040        let base_config = Processing::AuthorDate.config();
1041        assert_eq!(resolved.base, None);
1042        assert_eq!(
1043            resolved.sort,
1044            Some(SortEntry::Preset(SortPreset::AuthorTitleDate))
1045        );
1046        assert_eq!(resolved.group, base_config.group);
1047        assert_eq!(resolved.disambiguate, base_config.disambiguate);
1048    }
1049
1050    /// Test that resolved() without a base returns the stored fields as-is.
1051    #[test]
1052    fn test_processing_custom_resolved_without_base_is_identity() {
1053        let custom = ProcessingCustom {
1054            base: None,
1055            sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
1056            group: None,
1057            disambiguate: None,
1058        };
1059
1060        assert_eq!(custom.resolved(), custom);
1061    }
1062
1063    /// Test that regime_family and is_author_date_family delegate to the base
1064    /// when present and stay Custom without one.
1065    #[test]
1066    fn test_processing_custom_base_family_delegation() {
1067        // given: a delta on author-date and a base-less custom
1068        let with_base = Processing::Custom(ProcessingCustom {
1069            base: Some(ProcessingBase::AuthorDate),
1070            ..Default::default()
1071        });
1072        let without_base = Processing::Custom(ProcessingCustom::default());
1073
1074        // then: family checks follow the base only when one is set
1075        assert_eq!(with_base.regime_family(), RegimeFamily::AuthorDate);
1076        assert!(with_base.is_author_date_family());
1077        assert_eq!(without_base.regime_family(), RegimeFamily::Custom);
1078        assert!(!without_base.is_author_date_family());
1079
1080        // and: a numeric base maps to the numeric family
1081        let numeric_base = Processing::Custom(ProcessingCustom {
1082            base: Some(ProcessingBase::Numeric),
1083            ..Default::default()
1084        });
1085        assert_eq!(numeric_base.regime_family(), RegimeFamily::Numeric);
1086        assert!(!numeric_base.is_author_date_family());
1087    }
1088
1089    /// Test that default_bibliography_sort delegates to the base only when the
1090    /// custom carries no explicit sort.
1091    #[test]
1092    fn test_processing_custom_base_default_bibliography_sort() {
1093        // given: a base-carrying custom without an explicit sort
1094        let inherited = Processing::Custom(ProcessingCustom {
1095            base: Some(ProcessingBase::AuthorDate),
1096            ..Default::default()
1097        });
1098        // then: the base's preset default applies
1099        assert_eq!(
1100            inherited.default_bibliography_sort(),
1101            Some(SortPreset::AuthorDateTitle)
1102        );
1103
1104        // given: the same base with an explicit sort override
1105        let overridden = Processing::Custom(ProcessingCustom {
1106            base: Some(ProcessingBase::AuthorDate),
1107            sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
1108            ..Default::default()
1109        });
1110        // then: no preset default — the explicit sort resolves via config()
1111        assert_eq!(overridden.default_bibliography_sort(), None);
1112    }
1113
1114    /// Test that explicit null values in a custom map read as absent fields,
1115    /// matching derived-serde `Option` semantics and the JSON schema.
1116    #[test]
1117    fn test_processing_custom_map_accepts_explicit_nulls() {
1118        // given: a custom map with explicit nulls alongside a real field
1119        let parsed: Processing =
1120            serde_yaml::from_str("base: ~\nsort: author-title-date\ndisambiguate: null").unwrap();
1121
1122        // then: null fields are absent, the real field is kept
1123        assert_eq!(
1124            parsed,
1125            Processing::Custom(ProcessingCustom {
1126                base: None,
1127                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
1128                group: None,
1129                disambiguate: None,
1130            })
1131        );
1132    }
1133
1134    /// Test that invalid `base:` values are rejected at parse time.
1135    #[test]
1136    fn test_processing_custom_base_rejects_invalid_values() {
1137        // given: a nested map and an unknown preset name as base
1138        let nested = serde_yaml::from_str::<Processing>("base: { sort: author-date-title }");
1139        let unknown = serde_yaml::from_str::<Processing>("base: fancy-date");
1140
1141        // then: both fail to parse
1142        assert!(nested.is_err());
1143        assert!(unknown.is_err());
1144    }
1145
1146    /// Test that Disambiguation defaults have correct values.
1147    #[test]
1148    fn test_disambiguation_defaults() {
1149        let disambig = Disambiguation::default();
1150        assert!(disambig.names);
1151        assert!(!disambig.add_givenname);
1152        assert_eq!(disambig.givenname_rule, GivennameRule::ByCite);
1153        assert!(!disambig.year_suffix);
1154    }
1155
1156    /// Test that SortEntry::resolve() returns preset sort for Preset variant.
1157    #[test]
1158    fn test_sort_entry_resolve_preset() {
1159        let entry = SortEntry::Preset(SortPreset::AuthorDateTitle);
1160        let sort = entry.resolve();
1161
1162        // Verify it resolves to a valid Sort
1163        assert!(!sort.template.is_empty());
1164    }
1165
1166    /// Test that `Sort::group_sort()` maps author/year/title keys and skips
1167    /// `CitationNumber` (which has no group-sort equivalent).
1168    #[test]
1169    fn test_sort_group_sort_maps_keys_and_skips_citation_number() {
1170        let sort = Sort {
1171            shorten_names: false,
1172            render_substitutions: false,
1173            template: vec![
1174                SortSpec {
1175                    key: SortKey::Author,
1176                    ascending: true,
1177                },
1178                SortSpec {
1179                    key: SortKey::Year,
1180                    ascending: false,
1181                },
1182                SortSpec {
1183                    key: SortKey::Title,
1184                    ascending: true,
1185                },
1186                SortSpec {
1187                    key: SortKey::CitationNumber,
1188                    ascending: true,
1189                },
1190            ],
1191        };
1192
1193        let group_sort = sort.group_sort();
1194
1195        assert_eq!(group_sort.template.len(), 3);
1196        assert_eq!(group_sort.template[0].key, crate::grouping::SortKey::Author);
1197        assert!(group_sort.template[0].ascending);
1198        assert_eq!(group_sort.template[1].key, crate::grouping::SortKey::Issued);
1199        assert!(!group_sort.template[1].ascending);
1200        assert_eq!(group_sort.template[2].key, crate::grouping::SortKey::Title);
1201        assert!(group_sort.template[2].ascending);
1202    }
1203
1204    /// Test that SortEntry::resolve() returns explicit sort for Explicit variant.
1205    #[test]
1206    fn test_sort_entry_resolve_explicit() {
1207        let explicit = Sort {
1208            shorten_names: true,
1209            render_substitutions: false,
1210            template: vec![SortSpec {
1211                key: SortKey::Title,
1212                ascending: false,
1213            }],
1214        };
1215        let entry = SortEntry::Explicit(explicit.clone());
1216        let resolved = entry.resolve();
1217
1218        assert!(resolved.shorten_names);
1219        assert!(!resolved.render_substitutions);
1220        assert_eq!(resolved.template.len(), 1);
1221        assert_eq!(resolved.template[0].key, SortKey::Title);
1222        assert!(!resolved.template[0].ascending);
1223    }
1224}