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
23/// Label style preset conventions.
24#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
25#[cfg_attr(feature = "schema", derive(JsonSchema))]
26#[serde(rename_all = "kebab-case")]
27#[non_exhaustive]
28pub enum LabelPreset {
29    /// biblatex alphabetic / BibTeX alpha.bst: up to 4 authors, "+" marker, 2-digit year.
30    #[default]
31    Alpha,
32    /// DIN 1505-2: up to 3 authors, no et-al marker, 2-digit year.
33    Din,
34    /// American Mathematical Society: same label-generation algorithm as Alpha.
35    Ams,
36}
37
38/// Resolved label generation parameters after applying preset defaults.
39///
40/// Stores the resolved (effective) parameters for label citation mode, combining
41/// preset defaults with any user-specified overrides from `LabelConfig`.
42#[derive(Debug, Clone)]
43pub struct LabelParams {
44    /// Number of characters from a single author's family name.
45    pub single_author_chars: u8,
46    /// Number of characters per author when multiple authors are present.
47    pub multi_author_chars: u8,
48    /// Maximum number of authors before truncation (et-al).
49    pub et_al_min: u8,
50    /// Suffix to append when authors are truncated (e.g., "+").
51    pub et_al_marker: String,
52    /// Number of names to show in et-al truncation.
53    pub et_al_names: u8,
54    /// Number of year digits to use (typically 2 or 4).
55    pub year_digits: u8,
56}
57
58/// Configuration for label citation mode.
59#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
60#[cfg_attr(feature = "schema", derive(JsonSchema))]
61#[serde(rename_all = "kebab-case")]
62pub struct LabelConfig {
63    /// Preset that determines default parameters.
64    #[serde(default)]
65    pub preset: LabelPreset,
66    /// Chars taken from single author's family name. Preset default: 3 (Alpha/Ams), 4 (Din).
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub single_author_chars: Option<u8>,
69    /// Chars per author family name when 2+ authors. Preset default: 1.
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub multi_author_chars: Option<u8>,
72    /// Max authors before truncation. Alpha/Ams default: 4, Din default: 3.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub et_al_min: Option<u8>,
75    /// Suffix appended when truncated. Alpha/Ams default: "+", Din default: "".
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub et_al_marker: Option<String>,
78    /// Names shown when truncated (et-al). Alpha default: 3, Ams default: 4.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub et_al_names: Option<u8>,
81    /// Year digits: 2 or 4. Preset default: 2.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub year_digits: Option<u8>,
84}
85
86impl LabelConfig {
87    /// Resolve effective parameters by merging preset defaults with overrides.
88    ///
89    /// This method applies the `LabelPreset` defaults first, then applies any user-specified
90    /// overrides from optional fields. For example, if the preset is `Alpha` but `single_author_chars`
91    /// is specified, the specified value takes precedence over the preset default of 3.
92    ///
93    /// # Returns
94    ///
95    /// A `LabelParams` struct with all parameters resolved to concrete values.
96    pub fn effective_params(&self) -> LabelParams {
97        let (
98            default_single_author_chars,
99            default_multi_author_chars,
100            default_et_al_min,
101            default_marker,
102            default_et_al_names,
103        ) = match self.preset {
104            LabelPreset::Alpha => (3u8, 1u8, 4u8, "+".to_string(), 3u8),
105            LabelPreset::Ams => (3u8, 1u8, 4u8, "+".to_string(), 4u8),
106            LabelPreset::Din => (4u8, 1u8, 3u8, String::new(), 3u8),
107        };
108        LabelParams {
109            single_author_chars: self
110                .single_author_chars
111                .unwrap_or(default_single_author_chars),
112            multi_author_chars: self
113                .multi_author_chars
114                .unwrap_or(default_multi_author_chars),
115            et_al_min: self.et_al_min.unwrap_or(default_et_al_min),
116            et_al_marker: self.et_al_marker.clone().unwrap_or(default_marker),
117            et_al_names: self.et_al_names.unwrap_or(default_et_al_names),
118            year_digits: self.year_digits.unwrap_or(2),
119        }
120    }
121}
122
123/// Processing mode for citation/bibliography generation.
124///
125/// Determines how citations and bibliographies are sorted, grouped, and disambiguated.
126/// Can be specified as a simple string or with complex configuration maps:
127/// - A string: `"author-date"`, `"numeric"`, `"note"`, or `"label"`
128/// - A label config map: `{ label: { preset: din } }`
129/// - A custom config map: `{ sort: ..., group: ..., disambiguate: ... }`
130// `rename_all` is retained for `JsonSchema` derive (custom `Serialize` /
131// `Deserialize` impls below already use kebab-case names directly).
132#[derive(Debug, Default, PartialEq, Clone)]
133#[cfg_attr(feature = "schema", derive(JsonSchema))]
134#[cfg_attr(feature = "schema", schemars(rename_all = "kebab-case"))]
135#[non_exhaustive]
136pub enum Processing {
137    /// Author-date styles (e.g., APA, Chicago).
138    /// Default bibliography ordering: author, year, title.
139    #[default]
140    AuthorDate,
141    /// Numeric styles (e.g., IEEE, Nature).
142    /// Do not imply a bibliography sort; citations are numbered in order of appearance.
143    Numeric,
144    /// Note styles (e.g., Chicago Notes-Bibliography).
145    /// With a bibliography default to author, title, year ordering.
146    Note,
147    /// Label styles (e.g., Alpha, DIN 1505-2).
148    /// Default bibliography ordering: author, year, title.
149    Label(LabelConfig),
150    /// Fully custom processing behavior.
151    /// Explicit `sort` configuration remains authoritative.
152    Custom(ProcessingCustom),
153}
154
155/// How citation-item sorting is resolved when `citation.sort` is absent.
156///
157/// Determines whether citation clusters can be reordered automatically or only
158/// when explicitly configured.
159#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
160#[cfg_attr(feature = "schema", derive(JsonSchema))]
161#[serde(rename_all = "kebab-case")]
162pub enum CitationSortPolicy {
163    /// Only an explicit `citation.sort` can reorder multi-cite clusters.
164    ExplicitOnly,
165}
166
167/// Custom processing configuration.
168///
169/// Allows explicit specification of sorting, grouping, and disambiguation rules
170/// without relying on preset defaults.
171#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
172#[cfg_attr(feature = "schema", derive(JsonSchema))]
173#[serde(rename_all = "kebab-case")]
174pub struct ProcessingCustom {
175    /// Bibliography sorting configuration (optional).
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub sort: Option<SortEntry>,
178    /// Bibliography grouping configuration (optional).
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub group: Option<Group>,
181    /// Disambiguation settings (optional).
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub disambiguate: Option<Disambiguation>,
184}
185
186impl Processing {
187    /// Default bibliography sort for the processing family, if any.
188    ///
189    /// Returns the standard bibliography sort order for the processing mode:
190    /// - `AuthorDate` / `Label`: author, year, title
191    /// - `Note`: author, title, year
192    /// - `Numeric` / `Custom`: None (no automatic sort)
193    pub fn default_bibliography_sort(&self) -> Option<SortPreset> {
194        match self {
195            Processing::AuthorDate => Some(SortPreset::AuthorDateTitle),
196            Processing::Numeric => None,
197            Processing::Note => Some(SortPreset::AuthorTitleDate),
198            Processing::Label(_) => Some(SortPreset::AuthorDateTitle),
199            Processing::Custom(_) => None,
200        }
201    }
202
203    /// Citation sorting remains explicit-only for all processing families.
204    ///
205    /// All processing modes use `ExplicitOnly`, meaning citation clusters are only
206    /// reordered when explicitly configured via `citation.sort`.
207    pub fn default_citation_sort_policy(&self) -> CitationSortPolicy {
208        CitationSortPolicy::ExplicitOnly
209    }
210
211    /// Get the effective bibliography/disambiguation configuration for this processing mode.
212    ///
213    /// Returns a `ProcessingCustom` struct with the resolved configuration combining
214    /// preset defaults and user overrides. For `Custom` mode, returns the user-provided config as-is.
215    pub fn config(&self) -> ProcessingCustom {
216        match self {
217            Processing::AuthorDate => ProcessingCustom {
218                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
219                group: Some(Group {
220                    template: vec![SortKey::Author, SortKey::Year],
221                }),
222                disambiguate: Some(Disambiguation {
223                    names: true,
224                    add_givenname: true,
225                    givenname_rule: GivennameRule::default(),
226                    year_suffix: true,
227                }),
228            },
229            Processing::Numeric => ProcessingCustom {
230                sort: None,
231                group: None,
232                disambiguate: None,
233            },
234            Processing::Note => ProcessingCustom {
235                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
236                group: None,
237                disambiguate: Some(Disambiguation {
238                    names: true,
239                    add_givenname: false,
240                    givenname_rule: GivennameRule::default(),
241                    year_suffix: false,
242                }),
243            },
244            Processing::Label(_) => ProcessingCustom {
245                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
246                group: None,
247                disambiguate: Some(Disambiguation {
248                    names: false,
249                    add_givenname: false,
250                    givenname_rule: GivennameRule::default(),
251                    year_suffix: true,
252                }),
253            },
254            Processing::Custom(custom) => custom.clone(),
255        }
256    }
257}
258
259impl Serialize for Processing {
260    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
261    where
262        S: serde::Serializer,
263    {
264        match self {
265            Processing::AuthorDate => serializer.serialize_str("author-date"),
266            Processing::Numeric => serializer.serialize_str("numeric"),
267            Processing::Note => serializer.serialize_str("note"),
268            Processing::Label(config) => {
269                use serde::ser::SerializeMap;
270                let mut map = serializer.serialize_map(Some(1))?;
271                map.serialize_entry("label", config)?;
272                map.end()
273            }
274            // Emit `Custom` as a bare map so the YAML reads
275            // `processing:\n  sort: ...` instead of `processing: !custom`.
276            // The `visit_map` deserializer above already accepts this shape.
277            Processing::Custom(custom) => custom.serialize(serializer),
278        }
279    }
280}
281
282impl<'de> Deserialize<'de> for Processing {
283    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
284    where
285        D: serde::Deserializer<'de>,
286    {
287        use serde::de::{self, MapAccess, Visitor};
288
289        struct ProcessingVisitor;
290
291        impl<'de> Visitor<'de> for ProcessingVisitor {
292            type Value = Processing;
293
294            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
295                f.write_str("a processing mode string or map")
296            }
297
298            fn visit_str<E: de::Error>(self, v: &str) -> Result<Processing, E> {
299                match v {
300                    "author-date" => Ok(Processing::AuthorDate),
301                    "numeric" => Ok(Processing::Numeric),
302                    "note" => Ok(Processing::Note),
303                    "label" => Ok(Processing::Label(LabelConfig::default())),
304                    other => Err(E::unknown_variant(
305                        other,
306                        &["author-date", "numeric", "note", "label"],
307                    )),
308                }
309            }
310
311            fn visit_enum<A: de::EnumAccess<'de>>(self, data: A) -> Result<Processing, A::Error> {
312                use serde::de::VariantAccess;
313                let (variant, access) = data.variant::<String>()?;
314                match variant.as_str() {
315                    "custom" => {
316                        let custom: ProcessingCustom = access.newtype_variant()?;
317                        Ok(Processing::Custom(custom))
318                    }
319                    other => Err(de::Error::unknown_variant(
320                        other,
321                        &["author-date", "numeric", "note", "label", "custom"],
322                    )),
323                }
324            }
325
326            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Processing, A::Error> {
327                let key: String = map
328                    .next_key()?
329                    .ok_or_else(|| de::Error::invalid_length(0, &"1"))?;
330                match key.as_str() {
331                    "label" => {
332                        let config: LabelConfig = map.next_value()?;
333                        Ok(Processing::Label(config))
334                    }
335                    "sort" | "group" | "disambiguate" => {
336                        // This is a custom processing config
337                        // We need to deserialize the whole map as ProcessingCustom
338                        // Unfortunately we can't easily re-parse from the middle of map access.
339                        // Instead, collect fields and build manually
340                        let mut sort = None;
341                        let mut group = None;
342                        let mut disambiguate = None;
343
344                        // Handle the first key we already read
345                        match key.as_str() {
346                            "sort" => sort = Some(map.next_value()?),
347                            "group" => group = Some(map.next_value()?),
348                            "disambiguate" => disambiguate = Some(map.next_value()?),
349                            _ => {
350                                return Err(de::Error::unknown_field(
351                                    &key,
352                                    &["sort", "group", "disambiguate"],
353                                ));
354                            }
355                        }
356
357                        // Read remaining keys
358                        while let Some(k) = map.next_key::<String>()? {
359                            match k.as_str() {
360                                "sort" => sort = Some(map.next_value()?),
361                                "group" => group = Some(map.next_value()?),
362                                "disambiguate" => disambiguate = Some(map.next_value()?),
363                                other => {
364                                    return Err(de::Error::unknown_field(
365                                        other,
366                                        &["sort", "group", "disambiguate"],
367                                    ));
368                                }
369                            }
370                        }
371
372                        Ok(Processing::Custom(ProcessingCustom {
373                            sort,
374                            group,
375                            disambiguate,
376                        }))
377                    }
378                    other => Err(de::Error::unknown_field(
379                        other,
380                        &["label", "sort", "group", "disambiguate"],
381                    )),
382                }
383            }
384        }
385
386        deserializer.deserialize_any(ProcessingVisitor)
387    }
388}
389
390/// Controls which author positions receive given-name expansion during disambiguation.
391///
392/// Maps to CSL's `givenname-disambiguation-rule` attribute on `<citation>`.
393/// The engine collapses these to two scopes: `PrimaryName` and
394/// `PrimaryNameWithInitials` expand only the first (primary) author; all other
395/// values expand all positions. Initials vs full form is always driven by the
396/// contributor config's `initialize-with` / `name-form` settings.
397#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
398#[cfg_attr(feature = "schema", derive(JsonSchema))]
399#[serde(rename_all = "kebab-case")]
400#[non_exhaustive]
401pub enum GivennameRule {
402    /// Disambiguate per-cite with a minimal subset of names (CSL 1.0.1 default).
403    /// Engine behaviour: expand all positions (per-cite minimal-subset deferred).
404    #[default]
405    ByCite,
406    /// Expand given names for all name positions.
407    AllNames,
408    /// Expand given names (initials form) for all name positions.
409    AllNamesWithInitials,
410    /// Expand given name of the first (primary) author only.
411    PrimaryName,
412    /// Expand given name (initials form) of the first (primary) author only.
413    PrimaryNameWithInitials,
414}
415
416/// Disambiguation settings.
417///
418/// Controls how ambiguous citations are disambiguated in the output.
419#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
420#[cfg_attr(feature = "schema", derive(JsonSchema))]
421#[serde(rename_all = "kebab-case")]
422pub struct Disambiguation {
423    /// Whether to attempt disambiguation by expanding author names.
424    pub names: bool,
425    /// Whether to add given names to disambiguate similarly-named authors.
426    #[serde(default)]
427    pub add_givenname: bool,
428    /// Which author positions receive given-name expansion.
429    #[serde(default)]
430    pub givenname_rule: GivennameRule,
431    /// Whether to append year suffixes (a, b, c, ...) for multiple works from the same author-year.
432    pub year_suffix: bool,
433}
434
435impl Default for Disambiguation {
436    fn default() -> Self {
437        Self {
438            names: true,
439            add_givenname: false,
440            givenname_rule: GivennameRule::default(),
441            year_suffix: false,
442        }
443    }
444}
445
446/// Sorting configuration.
447///
448/// Specifies how bibliography entries are ordered.
449#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
450#[cfg_attr(feature = "schema", derive(JsonSchema))]
451#[serde(rename_all = "kebab-case")]
452pub struct Sort {
453    /// Whether to shorten name lists for sorting the same as for display.
454    #[serde(default)]
455    pub shorten_names: bool,
456    /// Whether to apply the same name substitutions during sorting as during rendering.
457    #[serde(default)]
458    pub render_substitutions: bool,
459    /// Sort keys in order of application.
460    pub template: Vec<SortSpec>,
461}
462
463/// Sort configuration: either a preset name or explicit configuration.
464///
465/// Can be a preset name like `author-date-title` or a full `Sort` struct with explicit settings.
466#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
467#[cfg_attr(feature = "schema", derive(JsonSchema))]
468#[serde(untagged)]
469pub enum SortEntry {
470    /// A named sort preset (e.g., `author-date-title`, `author-title-date`).
471    Preset(crate::presets::SortPreset),
472    /// Explicit sort configuration with custom keys and order.
473    Explicit(Sort),
474}
475
476impl SortEntry {
477    /// Resolve this entry to a concrete `Sort`.
478    ///
479    /// If this is a preset, returns the preset's sort definition. Otherwise returns the explicit sort as-is.
480    pub fn resolve(&self) -> Sort {
481        match self {
482            SortEntry::Preset(preset) => preset.sort(),
483            SortEntry::Explicit(sort) => sort.clone(),
484        }
485    }
486}
487
488/// A single sort specification.
489///
490/// Defines one sort dimension with its key and direction.
491#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
492#[cfg_attr(feature = "schema", derive(JsonSchema))]
493#[serde(rename_all = "kebab-case")]
494pub struct SortSpec {
495    /// The field to sort by.
496    pub key: SortKey,
497    /// Whether to sort in ascending order (default: true).
498    #[serde(default = "default_ascending")]
499    pub ascending: bool,
500}
501
502fn default_ascending() -> bool {
503    true
504}
505
506/// Available sort keys.
507///
508/// Specifies what field to sort bibliography entries by.
509#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
510#[cfg_attr(feature = "schema", derive(JsonSchema))]
511#[serde(rename_all = "kebab-case")]
512#[non_exhaustive]
513pub enum SortKey {
514    /// Sort by the work's author(s).
515    #[default]
516    Author,
517    /// Sort by publication year.
518    Year,
519    /// Sort by the work's title.
520    Title,
521    /// Sort by citation order (typically used for numeric styles).
522    CitationNumber,
523}
524
525/// Grouping configuration for bibliography.
526///
527/// Specifies how bibliography entries should be grouped in the output.
528#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
529#[cfg_attr(feature = "schema", derive(JsonSchema))]
530#[serde(rename_all = "kebab-case")]
531pub struct Group {
532    /// Sort keys used to define group boundaries (e.g., [Author, Year]).
533    pub template: Vec<SortKey>,
534}
535
536#[cfg(test)]
537#[allow(
538    clippy::unwrap_used,
539    clippy::expect_used,
540    clippy::panic,
541    clippy::indexing_slicing,
542    clippy::todo,
543    clippy::unimplemented,
544    clippy::unreachable,
545    clippy::get_unwrap,
546    reason = "Panicking is acceptable and often desired in tests."
547)]
548mod tests {
549    use super::*;
550
551    /// Test that LabelConfig::effective_params() applies Alpha preset defaults.
552    #[test]
553    fn test_label_config_alpha_preset_defaults() {
554        let config = LabelConfig {
555            preset: LabelPreset::Alpha,
556            single_author_chars: None,
557            multi_author_chars: None,
558            et_al_min: None,
559            et_al_marker: None,
560            et_al_names: None,
561            year_digits: None,
562        };
563
564        let params = config.effective_params();
565        assert_eq!(params.single_author_chars, 3);
566        assert_eq!(params.multi_author_chars, 1);
567        assert_eq!(params.et_al_min, 4);
568        assert_eq!(params.et_al_marker, "+");
569        assert_eq!(params.et_al_names, 3);
570        assert_eq!(params.year_digits, 2);
571    }
572
573    /// Test that LabelConfig overrides take precedence over preset defaults.
574    #[test]
575    fn test_label_config_alpha_with_overrides() {
576        let config = LabelConfig {
577            preset: LabelPreset::Alpha,
578            single_author_chars: Some(5),
579            multi_author_chars: Some(2),
580            et_al_min: Some(5),
581            et_al_marker: Some("*".to_string()),
582            et_al_names: Some(4),
583            year_digits: Some(4),
584        };
585
586        let params = config.effective_params();
587        assert_eq!(params.single_author_chars, 5);
588        assert_eq!(params.multi_author_chars, 2);
589        assert_eq!(params.et_al_min, 5);
590        assert_eq!(params.et_al_marker, "*");
591        assert_eq!(params.et_al_names, 4);
592        assert_eq!(params.year_digits, 4);
593    }
594
595    /// Test that LabelConfig::effective_params() applies Din preset defaults.
596    #[test]
597    fn test_label_config_din_preset_defaults() {
598        let config = LabelConfig {
599            preset: LabelPreset::Din,
600            single_author_chars: None,
601            multi_author_chars: None,
602            et_al_min: None,
603            et_al_marker: None,
604            et_al_names: None,
605            year_digits: None,
606        };
607
608        let params = config.effective_params();
609        assert_eq!(params.single_author_chars, 4);
610        assert_eq!(params.multi_author_chars, 1);
611        assert_eq!(params.et_al_min, 3);
612        assert_eq!(params.et_al_marker, "");
613        assert_eq!(params.et_al_names, 3);
614        assert_eq!(params.year_digits, 2);
615    }
616
617    /// Test that Processing::AuthorDate returns correct default sort.
618    #[test]
619    fn test_processing_author_date_default_bibliography_sort() {
620        let processing = Processing::AuthorDate;
621        let sort = processing.default_bibliography_sort();
622        assert_eq!(sort, Some(SortPreset::AuthorDateTitle));
623    }
624
625    /// Test that Processing::Numeric returns no default sort.
626    #[test]
627    fn test_processing_numeric_default_bibliography_sort() {
628        let processing = Processing::Numeric;
629        let sort = processing.default_bibliography_sort();
630        assert_eq!(sort, None);
631    }
632
633    /// Test that Processing::Note returns correct default sort.
634    #[test]
635    fn test_processing_note_default_bibliography_sort() {
636        let processing = Processing::Note;
637        let sort = processing.default_bibliography_sort();
638        assert_eq!(sort, Some(SortPreset::AuthorTitleDate));
639    }
640
641    /// Test that all Processing modes return ExplicitOnly citation sort policy.
642    #[test]
643    fn test_processing_citation_sort_policy() {
644        let modes = vec![
645            Processing::AuthorDate,
646            Processing::Numeric,
647            Processing::Note,
648            Processing::Label(LabelConfig::default()),
649            Processing::Custom(ProcessingCustom::default()),
650        ];
651
652        for mode in modes {
653            assert_eq!(
654                mode.default_citation_sort_policy(),
655                CitationSortPolicy::ExplicitOnly
656            );
657        }
658    }
659
660    /// Test that Processing::config() returns correct configuration for AuthorDate.
661    #[test]
662    fn test_processing_author_date_config() {
663        let processing = Processing::AuthorDate;
664        let config = processing.config();
665
666        assert!(config.sort.is_some());
667        assert!(config.group.is_some());
668        assert!(config.disambiguate.is_some());
669
670        let disambig = config.disambiguate.unwrap();
671        assert!(disambig.names);
672        assert!(disambig.add_givenname);
673        assert!(disambig.year_suffix);
674    }
675
676    /// Test that Disambiguation defaults have correct values.
677    #[test]
678    fn test_disambiguation_defaults() {
679        let disambig = Disambiguation::default();
680        assert!(disambig.names);
681        assert!(!disambig.add_givenname);
682        assert_eq!(disambig.givenname_rule, GivennameRule::ByCite);
683        assert!(!disambig.year_suffix);
684    }
685
686    /// Test that SortEntry::resolve() returns preset sort for Preset variant.
687    #[test]
688    fn test_sort_entry_resolve_preset() {
689        let entry = SortEntry::Preset(SortPreset::AuthorDateTitle);
690        let sort = entry.resolve();
691
692        // Verify it resolves to a valid Sort
693        assert!(!sort.template.is_empty());
694    }
695
696    /// Test that SortEntry::resolve() returns explicit sort for Explicit variant.
697    #[test]
698    fn test_sort_entry_resolve_explicit() {
699        let explicit = Sort {
700            shorten_names: true,
701            render_substitutions: false,
702            template: vec![SortSpec {
703                key: SortKey::Title,
704                ascending: false,
705            }],
706        };
707        let entry = SortEntry::Explicit(explicit.clone());
708        let resolved = entry.resolve();
709
710        assert!(resolved.shorten_names);
711        assert!(!resolved.render_substitutions);
712        assert_eq!(resolved.template.len(), 1);
713        assert_eq!(resolved.template[0].key, SortKey::Title);
714        assert!(!resolved.template[0].ascending);
715    }
716}