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/// Custom processing configuration.
184///
185/// Allows explicit specification of sorting, grouping, and disambiguation rules
186/// without relying on preset defaults.
187#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema", derive(JsonSchema))]
189#[serde(rename_all = "kebab-case")]
190pub struct ProcessingCustom {
191    /// Bibliography sorting configuration (optional).
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub sort: Option<SortEntry>,
194    /// Bibliography grouping configuration (optional).
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub group: Option<Group>,
197    /// Disambiguation settings (optional).
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub disambiguate: Option<Disambiguation>,
200}
201
202/// Coarse citation regime family for cross-regime compatibility checks.
203///
204/// Groups the `Processing` variants into mutually-exclusive citation-surface
205/// families. Used by `merge_style_overlay` and `StyleLineage::apply_regime_guard`
206/// to detect when a child's regime differs from its parent's, so that
207/// regime-specific citation sub-specs (integral, non-integral) can be reset
208/// rather than silently inherited.
209///
210/// See `docs/specs/CITATION_REGIME.md`.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub enum RegimeFamily {
213    /// All `AuthorDate*` variants: primary key is `(Author, Year)`.
214    AuthorDate,
215    /// `Numeric`: primary key is citation-order number.
216    Numeric,
217    /// `Note`: citations render as footnotes or endnotes.
218    Note,
219    /// `Label`: citations render as trigraph labels.
220    Label,
221    /// `Custom`: fully user-defined; never triggers automatic resets.
222    Custom,
223}
224
225fn author_date_config(names: bool, add_givenname: bool) -> ProcessingCustom {
226    ProcessingCustom {
227        sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
228        group: Some(Group {
229            template: vec![SortKey::Author, SortKey::Year],
230        }),
231        disambiguate: Some(Disambiguation {
232            names,
233            add_givenname,
234            givenname_rule: GivennameRule::default(),
235            year_suffix: true,
236        }),
237    }
238}
239
240impl Processing {
241    /// Default bibliography sort for the processing family, if any.
242    ///
243    /// Returns the standard bibliography sort order for the processing mode:
244    /// - `AuthorDate` / `Label`: author, year, title
245    /// - `Note`: author, title, year
246    /// - `Numeric` / `Custom`: None (no automatic sort)
247    pub fn default_bibliography_sort(&self) -> Option<SortPreset> {
248        match self {
249            Processing::AuthorDate
250            | Processing::AuthorDateGivenname
251            | Processing::AuthorDateNames
252            | Processing::AuthorDateFull => Some(SortPreset::AuthorDateTitle),
253            Processing::Numeric => None,
254            Processing::Note => Some(SortPreset::AuthorTitleDate),
255            Processing::Label(_) => Some(SortPreset::AuthorDateTitle),
256            Processing::Custom(_) => None,
257        }
258    }
259
260    /// Returns `true` for all author-date family variants.
261    ///
262    /// Centralizes the author-date family check so new variants don't require
263    /// updating scattered `matches!` blocks across the codebase.
264    pub fn is_author_date_family(&self) -> bool {
265        matches!(
266            self,
267            Self::AuthorDate
268                | Self::AuthorDateGivenname
269                | Self::AuthorDateNames
270                | Self::AuthorDateFull
271        )
272    }
273
274    /// Coarse citation regime family for cross-regime compatibility checks.
275    ///
276    /// Used during style inheritance to determine whether an inherited parent's
277    /// citation-mode sub-specs (integral, non-integral) belong to a different
278    /// regime and should be reset when the child supplies its own base template.
279    ///
280    /// `Custom` is its own family and never triggers automatic sub-spec resets,
281    /// preserving fully-custom authored styles.
282    ///
283    /// See `docs/specs/CITATION_REGIME.md` for the full invariant.
284    pub fn regime_family(&self) -> RegimeFamily {
285        match self {
286            Self::AuthorDate
287            | Self::AuthorDateGivenname
288            | Self::AuthorDateNames
289            | Self::AuthorDateFull => RegimeFamily::AuthorDate,
290            Self::Numeric => RegimeFamily::Numeric,
291            Self::Note => RegimeFamily::Note,
292            Self::Label(_) => RegimeFamily::Label,
293            Self::Custom(_) => RegimeFamily::Custom,
294        }
295    }
296
297    /// Citation sorting remains explicit-only for all processing families.
298    ///
299    /// All processing modes use `ExplicitOnly`, meaning citation clusters are only
300    /// reordered when explicitly configured via `citation.sort`.
301    pub fn default_citation_sort_policy(&self) -> CitationSortPolicy {
302        CitationSortPolicy::ExplicitOnly
303    }
304
305    /// Get the effective bibliography/disambiguation configuration for this processing mode.
306    ///
307    /// Returns a `ProcessingCustom` struct with the resolved configuration combining
308    /// preset defaults and user overrides. For `Custom` mode, returns the user-provided config as-is.
309    pub fn config(&self) -> ProcessingCustom {
310        match self {
311            Processing::AuthorDate => author_date_config(false, false),
312            Processing::AuthorDateGivenname => author_date_config(false, true),
313            Processing::AuthorDateNames => author_date_config(true, false),
314            Processing::AuthorDateFull => author_date_config(true, true),
315            Processing::Numeric => ProcessingCustom {
316                sort: None,
317                group: None,
318                disambiguate: None,
319            },
320            Processing::Note => ProcessingCustom {
321                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
322                group: None,
323                disambiguate: Some(Disambiguation {
324                    names: true,
325                    add_givenname: false,
326                    givenname_rule: GivennameRule::default(),
327                    year_suffix: false,
328                }),
329            },
330            Processing::Label(_) => ProcessingCustom {
331                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
332                group: None,
333                disambiguate: Some(Disambiguation {
334                    names: false,
335                    add_givenname: false,
336                    givenname_rule: GivennameRule::default(),
337                    year_suffix: true,
338                }),
339            },
340            Processing::Custom(custom) => custom.clone(),
341        }
342    }
343}
344
345impl Serialize for Processing {
346    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
347    where
348        S: serde::Serializer,
349    {
350        match self {
351            Processing::AuthorDate => serializer.serialize_str("author-date"),
352            Processing::AuthorDateGivenname => serializer.serialize_str("author-date-givenname"),
353            Processing::AuthorDateNames => serializer.serialize_str("author-date-names"),
354            Processing::AuthorDateFull => serializer.serialize_str("author-date-full"),
355            Processing::Numeric => serializer.serialize_str("numeric"),
356            Processing::Note => serializer.serialize_str("note"),
357            Processing::Label(config) => {
358                use serde::ser::SerializeMap;
359                let mut map = serializer.serialize_map(Some(1))?;
360                map.serialize_entry("label", config)?;
361                map.end()
362            }
363            // Emit `Custom` as a bare map so the YAML reads
364            // `processing:\n  sort: ...` instead of `processing: !custom`.
365            // The `visit_map` deserializer above already accepts this shape.
366            Processing::Custom(custom) => custom.serialize(serializer),
367        }
368    }
369}
370
371impl<'de> Deserialize<'de> for Processing {
372    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
373    where
374        D: serde::Deserializer<'de>,
375    {
376        use serde::de::{self, MapAccess, Visitor};
377
378        struct ProcessingVisitor;
379
380        impl<'de> Visitor<'de> for ProcessingVisitor {
381            type Value = Processing;
382
383            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
384                f.write_str("a processing mode string or map")
385            }
386
387            fn visit_str<E: de::Error>(self, v: &str) -> Result<Processing, E> {
388                match v {
389                    "author-date" => Ok(Processing::AuthorDate),
390                    "author-date-givenname" => Ok(Processing::AuthorDateGivenname),
391                    "author-date-names" => Ok(Processing::AuthorDateNames),
392                    "author-date-full" => Ok(Processing::AuthorDateFull),
393                    "numeric" => Ok(Processing::Numeric),
394                    "note" => Ok(Processing::Note),
395                    "label" => Ok(Processing::Label(LabelConfig::default())),
396                    other => Err(E::unknown_variant(other, PROCESSING_STRING_VARIANTS)),
397                }
398            }
399
400            fn visit_enum<A: de::EnumAccess<'de>>(self, data: A) -> Result<Processing, A::Error> {
401                use serde::de::VariantAccess;
402                let (variant, access) = data.variant::<String>()?;
403                match variant.as_str() {
404                    "custom" => {
405                        let custom: ProcessingCustom = access.newtype_variant()?;
406                        Ok(Processing::Custom(custom))
407                    }
408                    // `custom` is the only externally-tagged variant; named
409                    // string forms are handled by `visit_str` above.
410                    other => Err(de::Error::unknown_variant(other, &["custom"])),
411                }
412            }
413
414            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Processing, A::Error> {
415                let key: String = map
416                    .next_key()?
417                    .ok_or_else(|| de::Error::invalid_length(0, &"1"))?;
418                match key.as_str() {
419                    "label" => {
420                        let config: LabelConfig = map.next_value()?;
421                        Ok(Processing::Label(config))
422                    }
423                    "sort" | "group" | "disambiguate" => {
424                        // This is a custom processing config
425                        // We need to deserialize the whole map as ProcessingCustom
426                        // Unfortunately we can't easily re-parse from the middle of map access.
427                        // Instead, collect fields and build manually
428                        let mut sort = None;
429                        let mut group = None;
430                        let mut disambiguate = None;
431
432                        // Handle the first key we already read
433                        match key.as_str() {
434                            "sort" => sort = Some(map.next_value()?),
435                            "group" => group = Some(map.next_value()?),
436                            "disambiguate" => disambiguate = Some(map.next_value()?),
437                            _ => {
438                                return Err(de::Error::unknown_field(
439                                    &key,
440                                    &["sort", "group", "disambiguate"],
441                                ));
442                            }
443                        }
444
445                        // Read remaining keys
446                        while let Some(k) = map.next_key::<String>()? {
447                            match k.as_str() {
448                                "sort" => sort = Some(map.next_value()?),
449                                "group" => group = Some(map.next_value()?),
450                                "disambiguate" => disambiguate = Some(map.next_value()?),
451                                other => {
452                                    return Err(de::Error::unknown_field(
453                                        other,
454                                        &["sort", "group", "disambiguate"],
455                                    ));
456                                }
457                            }
458                        }
459
460                        Ok(Processing::Custom(ProcessingCustom {
461                            sort,
462                            group,
463                            disambiguate,
464                        }))
465                    }
466                    other => Err(de::Error::unknown_field(
467                        other,
468                        &["label", "sort", "group", "disambiguate"],
469                    )),
470                }
471            }
472        }
473
474        deserializer.deserialize_any(ProcessingVisitor)
475    }
476}
477
478/// Controls which author positions receive given-name expansion during disambiguation.
479///
480/// Maps to CSL's `givenname-disambiguation-rule` attribute on `<citation>`.
481/// The engine collapses these to two scopes: `PrimaryName` and
482/// `PrimaryNameWithInitials` expand only the first (primary) author; all other
483/// values expand all positions. Initials vs full form is always driven by the
484/// contributor config's `initialize-with` / `name-form` settings.
485#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
486#[cfg_attr(feature = "schema", derive(JsonSchema))]
487#[serde(rename_all = "kebab-case")]
488#[non_exhaustive]
489pub enum GivennameRule {
490    /// Disambiguate per-cite with a minimal subset of names (CSL 1.0.1 default).
491    /// Engine behaviour: expand all positions (per-cite minimal-subset deferred).
492    #[default]
493    ByCite,
494    /// Expand given names for all name positions.
495    AllNames,
496    /// Expand given names (initials form) for all name positions.
497    AllNamesWithInitials,
498    /// Expand given name of the first (primary) author only.
499    PrimaryName,
500    /// Expand given name (initials form) of the first (primary) author only.
501    PrimaryNameWithInitials,
502}
503
504/// Disambiguation settings.
505///
506/// Controls how ambiguous citations are disambiguated in the output.
507#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
508#[cfg_attr(feature = "schema", derive(JsonSchema))]
509#[serde(rename_all = "kebab-case")]
510pub struct Disambiguation {
511    /// Whether to attempt disambiguation by expanding author names.
512    pub names: bool,
513    /// Whether to add given names to disambiguate similarly-named authors.
514    #[serde(default)]
515    pub add_givenname: bool,
516    /// Which author positions receive given-name expansion.
517    #[serde(default)]
518    pub givenname_rule: GivennameRule,
519    /// Whether to append year suffixes (a, b, c, ...) for multiple works from the same author-year.
520    pub year_suffix: bool,
521}
522
523impl Default for Disambiguation {
524    fn default() -> Self {
525        Self {
526            names: true,
527            add_givenname: false,
528            givenname_rule: GivennameRule::default(),
529            year_suffix: false,
530        }
531    }
532}
533
534/// Sorting configuration.
535///
536/// Specifies how bibliography entries are ordered.
537#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
538#[cfg_attr(feature = "schema", derive(JsonSchema))]
539#[serde(rename_all = "kebab-case")]
540pub struct Sort {
541    /// Whether to shorten name lists for sorting the same as for display.
542    #[serde(default)]
543    pub shorten_names: bool,
544    /// Whether to apply the same name substitutions during sorting as during rendering.
545    #[serde(default)]
546    pub render_substitutions: bool,
547    /// Sort keys in order of application.
548    pub template: Vec<SortSpec>,
549}
550
551/// Sort configuration: either a preset name or explicit configuration.
552///
553/// Can be a preset name like `author-date-title` or a full `Sort` struct with explicit settings.
554#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
555#[cfg_attr(feature = "schema", derive(JsonSchema))]
556#[serde(untagged)]
557pub enum SortEntry {
558    /// A named sort preset (e.g., `author-date-title`, `author-title-date`).
559    Preset(crate::presets::SortPreset),
560    /// Explicit sort configuration with custom keys and order.
561    Explicit(Sort),
562}
563
564impl SortEntry {
565    /// Resolve this entry to a concrete `Sort`.
566    ///
567    /// If this is a preset, returns the preset's sort definition. Otherwise returns the explicit sort as-is.
568    pub fn resolve(&self) -> Sort {
569        match self {
570            SortEntry::Preset(preset) => preset.sort(),
571            SortEntry::Explicit(sort) => sort.clone(),
572        }
573    }
574}
575
576/// A single sort specification.
577///
578/// Defines one sort dimension with its key and direction.
579#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
580#[cfg_attr(feature = "schema", derive(JsonSchema))]
581#[serde(rename_all = "kebab-case")]
582pub struct SortSpec {
583    /// The field to sort by.
584    pub key: SortKey,
585    /// Whether to sort in ascending order (default: true).
586    #[serde(default = "default_ascending")]
587    pub ascending: bool,
588}
589
590fn default_ascending() -> bool {
591    true
592}
593
594/// Available sort keys.
595///
596/// Specifies what field to sort bibliography entries by.
597#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
598#[cfg_attr(feature = "schema", derive(JsonSchema))]
599#[serde(rename_all = "kebab-case")]
600#[non_exhaustive]
601pub enum SortKey {
602    /// Sort by the work's author(s).
603    #[default]
604    Author,
605    /// Sort by publication year.
606    Year,
607    /// Sort by the work's title.
608    Title,
609    /// Sort by citation order (typically used for numeric styles).
610    CitationNumber,
611}
612
613/// Grouping configuration for bibliography.
614///
615/// Specifies how bibliography entries should be grouped in the output.
616#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
617#[cfg_attr(feature = "schema", derive(JsonSchema))]
618#[serde(rename_all = "kebab-case")]
619pub struct Group {
620    /// Sort keys used to define group boundaries (e.g., [Author, Year]).
621    pub template: Vec<SortKey>,
622}
623
624#[cfg(test)]
625#[allow(
626    clippy::unwrap_used,
627    clippy::expect_used,
628    clippy::panic,
629    clippy::indexing_slicing,
630    clippy::todo,
631    clippy::unimplemented,
632    clippy::unreachable,
633    clippy::get_unwrap,
634    reason = "Panicking is acceptable and often desired in tests."
635)]
636mod tests {
637    use super::*;
638
639    /// Test that LabelConfig::effective_params() applies Alpha preset defaults.
640    #[test]
641    fn test_label_config_alpha_preset_defaults() {
642        let config = LabelConfig {
643            preset: LabelPreset::Alpha,
644            single_author_chars: None,
645            multi_author_chars: None,
646            et_al_min: None,
647            et_al_marker: None,
648            et_al_names: None,
649            year_digits: None,
650        };
651
652        let params = config.effective_params();
653        assert_eq!(params.single_author_chars, 3);
654        assert_eq!(params.multi_author_chars, 1);
655        assert_eq!(params.et_al_min, 4);
656        assert_eq!(params.et_al_marker, "+");
657        assert_eq!(params.et_al_names, 3);
658        assert_eq!(params.year_digits, 2);
659    }
660
661    /// Test that LabelConfig overrides take precedence over preset defaults.
662    #[test]
663    fn test_label_config_alpha_with_overrides() {
664        let config = LabelConfig {
665            preset: LabelPreset::Alpha,
666            single_author_chars: Some(5),
667            multi_author_chars: Some(2),
668            et_al_min: Some(5),
669            et_al_marker: Some("*".to_string()),
670            et_al_names: Some(4),
671            year_digits: Some(4),
672        };
673
674        let params = config.effective_params();
675        assert_eq!(params.single_author_chars, 5);
676        assert_eq!(params.multi_author_chars, 2);
677        assert_eq!(params.et_al_min, 5);
678        assert_eq!(params.et_al_marker, "*");
679        assert_eq!(params.et_al_names, 4);
680        assert_eq!(params.year_digits, 4);
681    }
682
683    /// Test that LabelConfig::effective_params() applies Din preset defaults.
684    #[test]
685    fn test_label_config_din_preset_defaults() {
686        let config = LabelConfig {
687            preset: LabelPreset::Din,
688            single_author_chars: None,
689            multi_author_chars: None,
690            et_al_min: None,
691            et_al_marker: None,
692            et_al_names: None,
693            year_digits: None,
694        };
695
696        let params = config.effective_params();
697        assert_eq!(params.single_author_chars, 4);
698        assert_eq!(params.multi_author_chars, 1);
699        assert_eq!(params.et_al_min, 3);
700        assert_eq!(params.et_al_marker, "");
701        assert_eq!(params.et_al_names, 3);
702        assert_eq!(params.year_digits, 2);
703    }
704
705    /// Test that LabelConfig::effective_params() applies AMS/CSL label defaults.
706    #[test]
707    fn test_label_config_ams_preset_defaults() {
708        let config = LabelConfig {
709            preset: LabelPreset::Ams,
710            single_author_chars: None,
711            multi_author_chars: None,
712            et_al_min: None,
713            et_al_marker: None,
714            et_al_names: None,
715            year_digits: None,
716        };
717
718        let params = config.effective_params();
719        assert_eq!(params.single_author_chars, 4);
720        assert_eq!(params.multi_author_chars, 1);
721        assert_eq!(params.et_al_min, 5);
722        assert_eq!(params.et_al_marker, "");
723        assert_eq!(params.et_al_names, 4);
724        assert_eq!(params.year_digits, 2);
725    }
726
727    /// Test that Processing::AuthorDate returns correct default sort.
728    #[test]
729    fn test_processing_author_date_default_bibliography_sort() {
730        let processing = Processing::AuthorDate;
731        let sort = processing.default_bibliography_sort();
732        assert_eq!(sort, Some(SortPreset::AuthorDateTitle));
733    }
734
735    /// Test that Processing::Numeric returns no default sort.
736    #[test]
737    fn test_processing_numeric_default_bibliography_sort() {
738        let processing = Processing::Numeric;
739        let sort = processing.default_bibliography_sort();
740        assert_eq!(sort, None);
741    }
742
743    /// Test that Processing::Note returns correct default sort.
744    #[test]
745    fn test_processing_note_default_bibliography_sort() {
746        let processing = Processing::Note;
747        let sort = processing.default_bibliography_sort();
748        assert_eq!(sort, Some(SortPreset::AuthorTitleDate));
749    }
750
751    /// Test that all Processing modes return ExplicitOnly citation sort policy.
752    #[test]
753    fn test_processing_citation_sort_policy() {
754        let modes = vec![
755            Processing::AuthorDate,
756            Processing::AuthorDateGivenname,
757            Processing::AuthorDateNames,
758            Processing::AuthorDateFull,
759            Processing::Numeric,
760            Processing::Note,
761            Processing::Label(LabelConfig::default()),
762            Processing::Custom(ProcessingCustom::default()),
763        ];
764
765        for mode in modes {
766            assert_eq!(
767                mode.default_citation_sort_policy(),
768                CitationSortPolicy::ExplicitOnly
769            );
770        }
771    }
772
773    /// Test that Processing::config() returns correct configuration for author-date variants.
774    #[test]
775    fn test_processing_author_date_variant_configs() {
776        let cases = [
777            (Processing::AuthorDate, false, false),
778            (Processing::AuthorDateGivenname, false, true),
779            (Processing::AuthorDateNames, true, false),
780            (Processing::AuthorDateFull, true, true),
781        ];
782
783        for (processing, names, add_givenname) in cases {
784            let config = processing.config();
785
786            assert_eq!(
787                config.sort,
788                Some(SortEntry::Preset(SortPreset::AuthorDateTitle))
789            );
790            assert_eq!(
791                config.group,
792                Some(Group {
793                    template: vec![SortKey::Author, SortKey::Year],
794                })
795            );
796
797            let disambig = config.disambiguate.unwrap();
798            assert_eq!(disambig.names, names);
799            assert_eq!(disambig.add_givenname, add_givenname);
800            assert_eq!(disambig.givenname_rule, GivennameRule::ByCite);
801            assert!(disambig.year_suffix);
802        }
803    }
804
805    /// Test that author-date processing variants round-trip through their public names.
806    #[test]
807    fn test_processing_author_date_variant_names() {
808        let cases = [
809            (Processing::AuthorDate, "author-date"),
810            (Processing::AuthorDateGivenname, "author-date-givenname"),
811            (Processing::AuthorDateNames, "author-date-names"),
812            (Processing::AuthorDateFull, "author-date-full"),
813        ];
814
815        for (processing, name) in cases {
816            let serialized = serde_yaml::to_string(&processing).unwrap();
817            assert_eq!(serialized.trim(), name);
818
819            let deserialized: Processing = serde_yaml::from_str(name).unwrap();
820            assert_eq!(deserialized, processing);
821        }
822    }
823
824    /// Test that Disambiguation defaults have correct values.
825    #[test]
826    fn test_disambiguation_defaults() {
827        let disambig = Disambiguation::default();
828        assert!(disambig.names);
829        assert!(!disambig.add_givenname);
830        assert_eq!(disambig.givenname_rule, GivennameRule::ByCite);
831        assert!(!disambig.year_suffix);
832    }
833
834    /// Test that SortEntry::resolve() returns preset sort for Preset variant.
835    #[test]
836    fn test_sort_entry_resolve_preset() {
837        let entry = SortEntry::Preset(SortPreset::AuthorDateTitle);
838        let sort = entry.resolve();
839
840        // Verify it resolves to a valid Sort
841        assert!(!sort.template.is_empty());
842    }
843
844    /// Test that SortEntry::resolve() returns explicit sort for Explicit variant.
845    #[test]
846    fn test_sort_entry_resolve_explicit() {
847        let explicit = Sort {
848            shorten_names: true,
849            render_substitutions: false,
850            template: vec![SortSpec {
851                key: SortKey::Title,
852                ascending: false,
853            }],
854        };
855        let entry = SortEntry::Explicit(explicit.clone());
856        let resolved = entry.resolve();
857
858        assert!(resolved.shorten_names);
859        assert!(!resolved.render_substitutions);
860        assert_eq!(resolved.template.len(), 1);
861        assert_eq!(resolved.template[0].key, SortKey::Title);
862        assert!(!resolved.template[0].ascending);
863    }
864}