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
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
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                    year_suffix: true,
226                }),
227            },
228            Processing::Numeric => ProcessingCustom {
229                sort: None,
230                group: None,
231                disambiguate: None,
232            },
233            Processing::Note => ProcessingCustom {
234                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
235                group: None,
236                disambiguate: Some(Disambiguation {
237                    names: true,
238                    add_givenname: false,
239                    year_suffix: false,
240                }),
241            },
242            Processing::Label(_) => ProcessingCustom {
243                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
244                group: None,
245                disambiguate: Some(Disambiguation {
246                    names: false,
247                    add_givenname: false,
248                    year_suffix: true,
249                }),
250            },
251            Processing::Custom(custom) => custom.clone(),
252        }
253    }
254}
255
256impl Serialize for Processing {
257    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
258    where
259        S: serde::Serializer,
260    {
261        match self {
262            Processing::AuthorDate => serializer.serialize_str("author-date"),
263            Processing::Numeric => serializer.serialize_str("numeric"),
264            Processing::Note => serializer.serialize_str("note"),
265            Processing::Label(config) => {
266                use serde::ser::SerializeMap;
267                let mut map = serializer.serialize_map(Some(1))?;
268                map.serialize_entry("label", config)?;
269                map.end()
270            }
271            // Emit `Custom` as a bare map so the YAML reads
272            // `processing:\n  sort: ...` instead of `processing: !custom`.
273            // The `visit_map` deserializer above already accepts this shape.
274            Processing::Custom(custom) => custom.serialize(serializer),
275        }
276    }
277}
278
279impl<'de> Deserialize<'de> for Processing {
280    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
281    where
282        D: serde::Deserializer<'de>,
283    {
284        use serde::de::{self, MapAccess, Visitor};
285
286        struct ProcessingVisitor;
287
288        impl<'de> Visitor<'de> for ProcessingVisitor {
289            type Value = Processing;
290
291            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
292                f.write_str("a processing mode string or map")
293            }
294
295            fn visit_str<E: de::Error>(self, v: &str) -> Result<Processing, E> {
296                match v {
297                    "author-date" => Ok(Processing::AuthorDate),
298                    "numeric" => Ok(Processing::Numeric),
299                    "note" => Ok(Processing::Note),
300                    "label" => Ok(Processing::Label(LabelConfig::default())),
301                    other => Err(E::unknown_variant(
302                        other,
303                        &["author-date", "numeric", "note", "label"],
304                    )),
305                }
306            }
307
308            fn visit_enum<A: de::EnumAccess<'de>>(self, data: A) -> Result<Processing, A::Error> {
309                use serde::de::VariantAccess;
310                let (variant, access) = data.variant::<String>()?;
311                match variant.as_str() {
312                    "custom" => {
313                        let custom: ProcessingCustom = access.newtype_variant()?;
314                        Ok(Processing::Custom(custom))
315                    }
316                    other => Err(de::Error::unknown_variant(
317                        other,
318                        &["author-date", "numeric", "note", "label", "custom"],
319                    )),
320                }
321            }
322
323            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Processing, A::Error> {
324                let key: String = map
325                    .next_key()?
326                    .ok_or_else(|| de::Error::invalid_length(0, &"1"))?;
327                match key.as_str() {
328                    "label" => {
329                        let config: LabelConfig = map.next_value()?;
330                        Ok(Processing::Label(config))
331                    }
332                    "sort" | "group" | "disambiguate" => {
333                        // This is a custom processing config
334                        // We need to deserialize the whole map as ProcessingCustom
335                        // Unfortunately we can't easily re-parse from the middle of map access.
336                        // Instead, collect fields and build manually
337                        let mut sort = None;
338                        let mut group = None;
339                        let mut disambiguate = None;
340
341                        // Handle the first key we already read
342                        match key.as_str() {
343                            "sort" => sort = Some(map.next_value()?),
344                            "group" => group = Some(map.next_value()?),
345                            "disambiguate" => disambiguate = Some(map.next_value()?),
346                            _ => {
347                                return Err(de::Error::unknown_field(
348                                    &key,
349                                    &["sort", "group", "disambiguate"],
350                                ));
351                            }
352                        }
353
354                        // Read remaining keys
355                        while let Some(k) = map.next_key::<String>()? {
356                            match k.as_str() {
357                                "sort" => sort = Some(map.next_value()?),
358                                "group" => group = Some(map.next_value()?),
359                                "disambiguate" => disambiguate = Some(map.next_value()?),
360                                other => {
361                                    return Err(de::Error::unknown_field(
362                                        other,
363                                        &["sort", "group", "disambiguate"],
364                                    ));
365                                }
366                            }
367                        }
368
369                        Ok(Processing::Custom(ProcessingCustom {
370                            sort,
371                            group,
372                            disambiguate,
373                        }))
374                    }
375                    other => Err(de::Error::unknown_field(
376                        other,
377                        &["label", "sort", "group", "disambiguate"],
378                    )),
379                }
380            }
381        }
382
383        deserializer.deserialize_any(ProcessingVisitor)
384    }
385}
386
387/// Disambiguation settings.
388///
389/// Controls how ambiguous citations are disambiguated in the output.
390#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
391#[cfg_attr(feature = "schema", derive(JsonSchema))]
392#[serde(rename_all = "kebab-case")]
393pub struct Disambiguation {
394    /// Whether to attempt disambiguation by expanding author names.
395    pub names: bool,
396    /// Whether to add given names to disambiguate similarly-named authors.
397    #[serde(default)]
398    pub add_givenname: bool,
399    /// Whether to append year suffixes (a, b, c, ...) for multiple works from the same author-year.
400    pub year_suffix: bool,
401}
402
403impl Default for Disambiguation {
404    fn default() -> Self {
405        Self {
406            names: true,
407            add_givenname: false,
408            year_suffix: false,
409        }
410    }
411}
412
413/// Sorting configuration.
414///
415/// Specifies how bibliography entries are ordered.
416#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
417#[cfg_attr(feature = "schema", derive(JsonSchema))]
418#[serde(rename_all = "kebab-case")]
419pub struct Sort {
420    /// Whether to shorten name lists for sorting the same as for display.
421    #[serde(default)]
422    pub shorten_names: bool,
423    /// Whether to apply the same name substitutions during sorting as during rendering.
424    #[serde(default)]
425    pub render_substitutions: bool,
426    /// Sort keys in order of application.
427    pub template: Vec<SortSpec>,
428}
429
430/// Sort configuration: either a preset name or explicit configuration.
431///
432/// Can be a preset name like `author-date-title` or a full `Sort` struct with explicit settings.
433#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
434#[cfg_attr(feature = "schema", derive(JsonSchema))]
435#[serde(untagged)]
436pub enum SortEntry {
437    /// A named sort preset (e.g., `author-date-title`, `author-title-date`).
438    Preset(crate::presets::SortPreset),
439    /// Explicit sort configuration with custom keys and order.
440    Explicit(Sort),
441}
442
443impl SortEntry {
444    /// Resolve this entry to a concrete `Sort`.
445    ///
446    /// If this is a preset, returns the preset's sort definition. Otherwise returns the explicit sort as-is.
447    pub fn resolve(&self) -> Sort {
448        match self {
449            SortEntry::Preset(preset) => preset.sort(),
450            SortEntry::Explicit(sort) => sort.clone(),
451        }
452    }
453}
454
455/// A single sort specification.
456///
457/// Defines one sort dimension with its key and direction.
458#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
459#[cfg_attr(feature = "schema", derive(JsonSchema))]
460#[serde(rename_all = "kebab-case")]
461pub struct SortSpec {
462    /// The field to sort by.
463    pub key: SortKey,
464    /// Whether to sort in ascending order (default: true).
465    #[serde(default = "default_ascending")]
466    pub ascending: bool,
467}
468
469fn default_ascending() -> bool {
470    true
471}
472
473/// Available sort keys.
474///
475/// Specifies what field to sort bibliography entries by.
476#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
477#[cfg_attr(feature = "schema", derive(JsonSchema))]
478#[serde(rename_all = "kebab-case")]
479#[non_exhaustive]
480pub enum SortKey {
481    /// Sort by the work's author(s).
482    #[default]
483    Author,
484    /// Sort by publication year.
485    Year,
486    /// Sort by the work's title.
487    Title,
488    /// Sort by citation order (typically used for numeric styles).
489    CitationNumber,
490}
491
492/// Grouping configuration for bibliography.
493///
494/// Specifies how bibliography entries should be grouped in the output.
495#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
496#[cfg_attr(feature = "schema", derive(JsonSchema))]
497#[serde(rename_all = "kebab-case")]
498pub struct Group {
499    /// Sort keys used to define group boundaries (e.g., [Author, Year]).
500    pub template: Vec<SortKey>,
501}
502
503#[cfg(test)]
504#[allow(
505    clippy::unwrap_used,
506    clippy::expect_used,
507    clippy::panic,
508    clippy::indexing_slicing,
509    clippy::todo,
510    clippy::unimplemented,
511    clippy::unreachable,
512    clippy::get_unwrap,
513    reason = "Panicking is acceptable and often desired in tests."
514)]
515mod tests {
516    use super::*;
517
518    /// Test that LabelConfig::effective_params() applies Alpha preset defaults.
519    #[test]
520    fn test_label_config_alpha_preset_defaults() {
521        let config = LabelConfig {
522            preset: LabelPreset::Alpha,
523            single_author_chars: None,
524            multi_author_chars: None,
525            et_al_min: None,
526            et_al_marker: None,
527            et_al_names: None,
528            year_digits: None,
529        };
530
531        let params = config.effective_params();
532        assert_eq!(params.single_author_chars, 3);
533        assert_eq!(params.multi_author_chars, 1);
534        assert_eq!(params.et_al_min, 4);
535        assert_eq!(params.et_al_marker, "+");
536        assert_eq!(params.et_al_names, 3);
537        assert_eq!(params.year_digits, 2);
538    }
539
540    /// Test that LabelConfig overrides take precedence over preset defaults.
541    #[test]
542    fn test_label_config_alpha_with_overrides() {
543        let config = LabelConfig {
544            preset: LabelPreset::Alpha,
545            single_author_chars: Some(5),
546            multi_author_chars: Some(2),
547            et_al_min: Some(5),
548            et_al_marker: Some("*".to_string()),
549            et_al_names: Some(4),
550            year_digits: Some(4),
551        };
552
553        let params = config.effective_params();
554        assert_eq!(params.single_author_chars, 5);
555        assert_eq!(params.multi_author_chars, 2);
556        assert_eq!(params.et_al_min, 5);
557        assert_eq!(params.et_al_marker, "*");
558        assert_eq!(params.et_al_names, 4);
559        assert_eq!(params.year_digits, 4);
560    }
561
562    /// Test that LabelConfig::effective_params() applies Din preset defaults.
563    #[test]
564    fn test_label_config_din_preset_defaults() {
565        let config = LabelConfig {
566            preset: LabelPreset::Din,
567            single_author_chars: None,
568            multi_author_chars: None,
569            et_al_min: None,
570            et_al_marker: None,
571            et_al_names: None,
572            year_digits: None,
573        };
574
575        let params = config.effective_params();
576        assert_eq!(params.single_author_chars, 4);
577        assert_eq!(params.multi_author_chars, 1);
578        assert_eq!(params.et_al_min, 3);
579        assert_eq!(params.et_al_marker, "");
580        assert_eq!(params.et_al_names, 3);
581        assert_eq!(params.year_digits, 2);
582    }
583
584    /// Test that Processing::AuthorDate returns correct default sort.
585    #[test]
586    fn test_processing_author_date_default_bibliography_sort() {
587        let processing = Processing::AuthorDate;
588        let sort = processing.default_bibliography_sort();
589        assert_eq!(sort, Some(SortPreset::AuthorDateTitle));
590    }
591
592    /// Test that Processing::Numeric returns no default sort.
593    #[test]
594    fn test_processing_numeric_default_bibliography_sort() {
595        let processing = Processing::Numeric;
596        let sort = processing.default_bibliography_sort();
597        assert_eq!(sort, None);
598    }
599
600    /// Test that Processing::Note returns correct default sort.
601    #[test]
602    fn test_processing_note_default_bibliography_sort() {
603        let processing = Processing::Note;
604        let sort = processing.default_bibliography_sort();
605        assert_eq!(sort, Some(SortPreset::AuthorTitleDate));
606    }
607
608    /// Test that all Processing modes return ExplicitOnly citation sort policy.
609    #[test]
610    fn test_processing_citation_sort_policy() {
611        let modes = vec![
612            Processing::AuthorDate,
613            Processing::Numeric,
614            Processing::Note,
615            Processing::Label(LabelConfig::default()),
616            Processing::Custom(ProcessingCustom::default()),
617        ];
618
619        for mode in modes {
620            assert_eq!(
621                mode.default_citation_sort_policy(),
622                CitationSortPolicy::ExplicitOnly
623            );
624        }
625    }
626
627    /// Test that Processing::config() returns correct configuration for AuthorDate.
628    #[test]
629    fn test_processing_author_date_config() {
630        let processing = Processing::AuthorDate;
631        let config = processing.config();
632
633        assert!(config.sort.is_some());
634        assert!(config.group.is_some());
635        assert!(config.disambiguate.is_some());
636
637        let disambig = config.disambiguate.unwrap();
638        assert!(disambig.names);
639        assert!(disambig.add_givenname);
640        assert!(disambig.year_suffix);
641    }
642
643    /// Test that Disambiguation defaults have correct values.
644    #[test]
645    fn test_disambiguation_defaults() {
646        let disambig = Disambiguation::default();
647        assert!(disambig.names);
648        assert!(!disambig.add_givenname);
649        assert!(!disambig.year_suffix);
650    }
651
652    /// Test that SortEntry::resolve() returns preset sort for Preset variant.
653    #[test]
654    fn test_sort_entry_resolve_preset() {
655        let entry = SortEntry::Preset(SortPreset::AuthorDateTitle);
656        let sort = entry.resolve();
657
658        // Verify it resolves to a valid Sort
659        assert!(!sort.template.is_empty());
660    }
661
662    /// Test that SortEntry::resolve() returns explicit sort for Explicit variant.
663    #[test]
664    fn test_sort_entry_resolve_explicit() {
665        let explicit = Sort {
666            shorten_names: true,
667            render_substitutions: false,
668            template: vec![SortSpec {
669                key: SortKey::Title,
670                ascending: false,
671            }],
672        };
673        let entry = SortEntry::Explicit(explicit.clone());
674        let resolved = entry.resolve();
675
676        assert!(resolved.shorten_names);
677        assert!(!resolved.render_substitutions);
678        assert_eq!(resolved.template.len(), 1);
679        assert_eq!(resolved.template[0].key, SortKey::Title);
680        assert!(!resolved.template[0].ascending);
681    }
682}