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