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#[derive(Debug, Default, PartialEq, Clone, Serialize)]
131#[cfg_attr(feature = "schema", derive(JsonSchema))]
132#[serde(rename_all = "kebab-case")]
133#[non_exhaustive]
134pub enum Processing {
135    /// Author-date styles (e.g., APA, Chicago).
136    /// Default bibliography ordering: author, year, title.
137    #[default]
138    AuthorDate,
139    /// Numeric styles (e.g., IEEE, Nature).
140    /// Do not imply a bibliography sort; citations are numbered in order of appearance.
141    Numeric,
142    /// Note styles (e.g., Chicago Notes-Bibliography).
143    /// With a bibliography default to author, title, year ordering.
144    Note,
145    /// Label styles (e.g., Alpha, DIN 1505-2).
146    /// Default bibliography ordering: author, year, title.
147    Label(LabelConfig),
148    /// Fully custom processing behavior.
149    /// Explicit `sort` configuration remains authoritative.
150    Custom(ProcessingCustom),
151}
152
153/// How citation-item sorting is resolved when `citation.sort` is absent.
154///
155/// Determines whether citation clusters can be reordered automatically or only
156/// when explicitly configured.
157#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(JsonSchema))]
159#[serde(rename_all = "kebab-case")]
160pub enum CitationSortPolicy {
161    /// Only an explicit `citation.sort` can reorder multi-cite clusters.
162    ExplicitOnly,
163}
164
165/// Custom processing configuration.
166///
167/// Allows explicit specification of sorting, grouping, and disambiguation rules
168/// without relying on preset defaults.
169#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize)]
170#[cfg_attr(feature = "schema", derive(JsonSchema))]
171#[serde(rename_all = "kebab-case")]
172pub struct ProcessingCustom {
173    /// Bibliography sorting configuration (optional).
174    #[serde(skip_serializing_if = "Option::is_none")]
175    pub sort: Option<SortEntry>,
176    /// Bibliography grouping configuration (optional).
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub group: Option<Group>,
179    /// Disambiguation settings (optional).
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub disambiguate: Option<Disambiguation>,
182}
183
184impl Processing {
185    /// Default bibliography sort for the processing family, if any.
186    ///
187    /// Returns the standard bibliography sort order for the processing mode:
188    /// - `AuthorDate` / `Label`: author, year, title
189    /// - `Note`: author, title, year
190    /// - `Numeric` / `Custom`: None (no automatic sort)
191    pub fn default_bibliography_sort(&self) -> Option<SortPreset> {
192        match self {
193            Processing::AuthorDate => Some(SortPreset::AuthorDateTitle),
194            Processing::Numeric => None,
195            Processing::Note => Some(SortPreset::AuthorTitleDate),
196            Processing::Label(_) => Some(SortPreset::AuthorDateTitle),
197            Processing::Custom(_) => None,
198        }
199    }
200
201    /// Citation sorting remains explicit-only for all processing families.
202    ///
203    /// All processing modes use `ExplicitOnly`, meaning citation clusters are only
204    /// reordered when explicitly configured via `citation.sort`.
205    pub fn default_citation_sort_policy(&self) -> CitationSortPolicy {
206        CitationSortPolicy::ExplicitOnly
207    }
208
209    /// Get the effective bibliography/disambiguation configuration for this processing mode.
210    ///
211    /// Returns a `ProcessingCustom` struct with the resolved configuration combining
212    /// preset defaults and user overrides. For `Custom` mode, returns the user-provided config as-is.
213    pub fn config(&self) -> ProcessingCustom {
214        match self {
215            Processing::AuthorDate => ProcessingCustom {
216                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
217                group: Some(Group {
218                    template: vec![SortKey::Author, SortKey::Year],
219                }),
220                disambiguate: Some(Disambiguation {
221                    names: true,
222                    add_givenname: true,
223                    year_suffix: true,
224                }),
225            },
226            Processing::Numeric => ProcessingCustom {
227                sort: None,
228                group: None,
229                disambiguate: None,
230            },
231            Processing::Note => ProcessingCustom {
232                sort: Some(SortEntry::Preset(SortPreset::AuthorTitleDate)),
233                group: None,
234                disambiguate: Some(Disambiguation {
235                    names: true,
236                    add_givenname: false,
237                    year_suffix: false,
238                }),
239            },
240            Processing::Label(_) => ProcessingCustom {
241                sort: Some(SortEntry::Preset(SortPreset::AuthorDateTitle)),
242                group: None,
243                disambiguate: Some(Disambiguation {
244                    names: false,
245                    add_givenname: false,
246                    year_suffix: true,
247                }),
248            },
249            Processing::Custom(custom) => custom.clone(),
250        }
251    }
252}
253
254impl<'de> Deserialize<'de> for Processing {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: serde::Deserializer<'de>,
258    {
259        use serde::de::{self, MapAccess, Visitor};
260
261        struct ProcessingVisitor;
262
263        impl<'de> Visitor<'de> for ProcessingVisitor {
264            type Value = Processing;
265
266            fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
267                f.write_str("a processing mode string or map")
268            }
269
270            fn visit_str<E: de::Error>(self, v: &str) -> Result<Processing, E> {
271                match v {
272                    "author-date" => Ok(Processing::AuthorDate),
273                    "numeric" => Ok(Processing::Numeric),
274                    "note" => Ok(Processing::Note),
275                    "label" => Ok(Processing::Label(LabelConfig::default())),
276                    other => Err(E::unknown_variant(
277                        other,
278                        &["author-date", "numeric", "note", "label"],
279                    )),
280                }
281            }
282
283            fn visit_enum<A: de::EnumAccess<'de>>(self, data: A) -> Result<Processing, A::Error> {
284                use serde::de::VariantAccess;
285                let (variant, access) = data.variant::<String>()?;
286                match variant.as_str() {
287                    "custom" => {
288                        let custom: ProcessingCustom = access.newtype_variant()?;
289                        Ok(Processing::Custom(custom))
290                    }
291                    other => Err(de::Error::unknown_variant(
292                        other,
293                        &["author-date", "numeric", "note", "label", "custom"],
294                    )),
295                }
296            }
297
298            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Processing, A::Error> {
299                let key: String = map
300                    .next_key()?
301                    .ok_or_else(|| de::Error::invalid_length(0, &"1"))?;
302                match key.as_str() {
303                    "label" => {
304                        let config: LabelConfig = map.next_value()?;
305                        Ok(Processing::Label(config))
306                    }
307                    "sort" | "group" | "disambiguate" => {
308                        // This is a custom processing config
309                        // We need to deserialize the whole map as ProcessingCustom
310                        // Unfortunately we can't easily re-parse from the middle of map access.
311                        // Instead, collect fields and build manually
312                        let mut sort = None;
313                        let mut group = None;
314                        let mut disambiguate = None;
315
316                        // Handle the first key we already read
317                        match key.as_str() {
318                            "sort" => sort = Some(map.next_value()?),
319                            "group" => group = Some(map.next_value()?),
320                            "disambiguate" => disambiguate = Some(map.next_value()?),
321                            _ => {
322                                return Err(de::Error::unknown_field(
323                                    &key,
324                                    &["sort", "group", "disambiguate"],
325                                ));
326                            }
327                        }
328
329                        // Read remaining keys
330                        while let Some(k) = map.next_key::<String>()? {
331                            match k.as_str() {
332                                "sort" => sort = Some(map.next_value()?),
333                                "group" => group = Some(map.next_value()?),
334                                "disambiguate" => disambiguate = Some(map.next_value()?),
335                                other => {
336                                    return Err(de::Error::unknown_field(
337                                        other,
338                                        &["sort", "group", "disambiguate"],
339                                    ));
340                                }
341                            }
342                        }
343
344                        Ok(Processing::Custom(ProcessingCustom {
345                            sort,
346                            group,
347                            disambiguate,
348                        }))
349                    }
350                    other => Err(de::Error::unknown_field(
351                        other,
352                        &["label", "sort", "group", "disambiguate"],
353                    )),
354                }
355            }
356        }
357
358        deserializer.deserialize_any(ProcessingVisitor)
359    }
360}
361
362/// Disambiguation settings.
363///
364/// Controls how ambiguous citations are disambiguated in the output.
365#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
366#[cfg_attr(feature = "schema", derive(JsonSchema))]
367#[serde(rename_all = "kebab-case")]
368pub struct Disambiguation {
369    /// Whether to attempt disambiguation by expanding author names.
370    pub names: bool,
371    /// Whether to add given names to disambiguate similarly-named authors.
372    #[serde(default)]
373    pub add_givenname: bool,
374    /// Whether to append year suffixes (a, b, c, ...) for multiple works from the same author-year.
375    pub year_suffix: bool,
376}
377
378impl Default for Disambiguation {
379    fn default() -> Self {
380        Self {
381            names: true,
382            add_givenname: false,
383            year_suffix: false,
384        }
385    }
386}
387
388/// Sorting configuration.
389///
390/// Specifies how bibliography entries are ordered.
391#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
392#[cfg_attr(feature = "schema", derive(JsonSchema))]
393#[serde(rename_all = "kebab-case")]
394pub struct Sort {
395    /// Whether to shorten name lists for sorting the same as for display.
396    #[serde(default)]
397    pub shorten_names: bool,
398    /// Whether to apply the same name substitutions during sorting as during rendering.
399    #[serde(default)]
400    pub render_substitutions: bool,
401    /// Sort keys in order of application.
402    pub template: Vec<SortSpec>,
403}
404
405/// Sort configuration: either a preset name or explicit configuration.
406///
407/// Can be a preset name like `author-date-title` or a full `Sort` struct with explicit settings.
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
409#[cfg_attr(feature = "schema", derive(JsonSchema))]
410#[serde(untagged)]
411pub enum SortEntry {
412    /// A named sort preset (e.g., `author-date-title`, `author-title-date`).
413    Preset(crate::presets::SortPreset),
414    /// Explicit sort configuration with custom keys and order.
415    Explicit(Sort),
416}
417
418impl SortEntry {
419    /// Resolve this entry to a concrete `Sort`.
420    ///
421    /// If this is a preset, returns the preset's sort definition. Otherwise returns the explicit sort as-is.
422    pub fn resolve(&self) -> Sort {
423        match self {
424            SortEntry::Preset(preset) => preset.sort(),
425            SortEntry::Explicit(sort) => sort.clone(),
426        }
427    }
428}
429
430/// A single sort specification.
431///
432/// Defines one sort dimension with its key and direction.
433#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
434#[cfg_attr(feature = "schema", derive(JsonSchema))]
435#[serde(rename_all = "kebab-case")]
436pub struct SortSpec {
437    /// The field to sort by.
438    pub key: SortKey,
439    /// Whether to sort in ascending order (default: true).
440    #[serde(default = "default_ascending")]
441    pub ascending: bool,
442}
443
444fn default_ascending() -> bool {
445    true
446}
447
448/// Available sort keys.
449///
450/// Specifies what field to sort bibliography entries by.
451#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)]
452#[cfg_attr(feature = "schema", derive(JsonSchema))]
453#[serde(rename_all = "kebab-case")]
454#[non_exhaustive]
455pub enum SortKey {
456    /// Sort by the work's author(s).
457    #[default]
458    Author,
459    /// Sort by publication year.
460    Year,
461    /// Sort by the work's title.
462    Title,
463    /// Sort by citation order (typically used for numeric styles).
464    CitationNumber,
465}
466
467/// Grouping configuration for bibliography.
468///
469/// Specifies how bibliography entries should be grouped in the output.
470#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
471#[cfg_attr(feature = "schema", derive(JsonSchema))]
472#[serde(rename_all = "kebab-case")]
473pub struct Group {
474    /// Sort keys used to define group boundaries (e.g., [Author, Year]).
475    pub template: Vec<SortKey>,
476}
477
478#[cfg(test)]
479#[allow(
480    clippy::unwrap_used,
481    clippy::expect_used,
482    clippy::panic,
483    clippy::indexing_slicing,
484    clippy::todo,
485    clippy::unimplemented,
486    clippy::unreachable,
487    clippy::get_unwrap,
488    reason = "Panicking is acceptable and often desired in tests."
489)]
490mod tests {
491    use super::*;
492
493    /// Test that LabelConfig::effective_params() applies Alpha preset defaults.
494    #[test]
495    fn test_label_config_alpha_preset_defaults() {
496        let config = LabelConfig {
497            preset: LabelPreset::Alpha,
498            single_author_chars: None,
499            multi_author_chars: None,
500            et_al_min: None,
501            et_al_marker: None,
502            et_al_names: None,
503            year_digits: None,
504        };
505
506        let params = config.effective_params();
507        assert_eq!(params.single_author_chars, 3);
508        assert_eq!(params.multi_author_chars, 1);
509        assert_eq!(params.et_al_min, 4);
510        assert_eq!(params.et_al_marker, "+");
511        assert_eq!(params.et_al_names, 3);
512        assert_eq!(params.year_digits, 2);
513    }
514
515    /// Test that LabelConfig overrides take precedence over preset defaults.
516    #[test]
517    fn test_label_config_alpha_with_overrides() {
518        let config = LabelConfig {
519            preset: LabelPreset::Alpha,
520            single_author_chars: Some(5),
521            multi_author_chars: Some(2),
522            et_al_min: Some(5),
523            et_al_marker: Some("*".to_string()),
524            et_al_names: Some(4),
525            year_digits: Some(4),
526        };
527
528        let params = config.effective_params();
529        assert_eq!(params.single_author_chars, 5);
530        assert_eq!(params.multi_author_chars, 2);
531        assert_eq!(params.et_al_min, 5);
532        assert_eq!(params.et_al_marker, "*");
533        assert_eq!(params.et_al_names, 4);
534        assert_eq!(params.year_digits, 4);
535    }
536
537    /// Test that LabelConfig::effective_params() applies Din preset defaults.
538    #[test]
539    fn test_label_config_din_preset_defaults() {
540        let config = LabelConfig {
541            preset: LabelPreset::Din,
542            single_author_chars: None,
543            multi_author_chars: None,
544            et_al_min: None,
545            et_al_marker: None,
546            et_al_names: None,
547            year_digits: None,
548        };
549
550        let params = config.effective_params();
551        assert_eq!(params.single_author_chars, 4);
552        assert_eq!(params.multi_author_chars, 1);
553        assert_eq!(params.et_al_min, 3);
554        assert_eq!(params.et_al_marker, "");
555        assert_eq!(params.et_al_names, 3);
556        assert_eq!(params.year_digits, 2);
557    }
558
559    /// Test that Processing::AuthorDate returns correct default sort.
560    #[test]
561    fn test_processing_author_date_default_bibliography_sort() {
562        let processing = Processing::AuthorDate;
563        let sort = processing.default_bibliography_sort();
564        assert_eq!(sort, Some(SortPreset::AuthorDateTitle));
565    }
566
567    /// Test that Processing::Numeric returns no default sort.
568    #[test]
569    fn test_processing_numeric_default_bibliography_sort() {
570        let processing = Processing::Numeric;
571        let sort = processing.default_bibliography_sort();
572        assert_eq!(sort, None);
573    }
574
575    /// Test that Processing::Note returns correct default sort.
576    #[test]
577    fn test_processing_note_default_bibliography_sort() {
578        let processing = Processing::Note;
579        let sort = processing.default_bibliography_sort();
580        assert_eq!(sort, Some(SortPreset::AuthorTitleDate));
581    }
582
583    /// Test that all Processing modes return ExplicitOnly citation sort policy.
584    #[test]
585    fn test_processing_citation_sort_policy() {
586        let modes = vec![
587            Processing::AuthorDate,
588            Processing::Numeric,
589            Processing::Note,
590            Processing::Label(LabelConfig::default()),
591            Processing::Custom(ProcessingCustom::default()),
592        ];
593
594        for mode in modes {
595            assert_eq!(
596                mode.default_citation_sort_policy(),
597                CitationSortPolicy::ExplicitOnly
598            );
599        }
600    }
601
602    /// Test that Processing::config() returns correct configuration for AuthorDate.
603    #[test]
604    fn test_processing_author_date_config() {
605        let processing = Processing::AuthorDate;
606        let config = processing.config();
607
608        assert!(config.sort.is_some());
609        assert!(config.group.is_some());
610        assert!(config.disambiguate.is_some());
611
612        let disambig = config.disambiguate.unwrap();
613        assert!(disambig.names);
614        assert!(disambig.add_givenname);
615        assert!(disambig.year_suffix);
616    }
617
618    /// Test that Disambiguation defaults have correct values.
619    #[test]
620    fn test_disambiguation_defaults() {
621        let disambig = Disambiguation::default();
622        assert!(disambig.names);
623        assert!(!disambig.add_givenname);
624        assert!(!disambig.year_suffix);
625    }
626
627    /// Test that SortEntry::resolve() returns preset sort for Preset variant.
628    #[test]
629    fn test_sort_entry_resolve_preset() {
630        let entry = SortEntry::Preset(SortPreset::AuthorDateTitle);
631        let sort = entry.resolve();
632
633        // Verify it resolves to a valid Sort
634        assert!(!sort.template.is_empty());
635    }
636
637    /// Test that SortEntry::resolve() returns explicit sort for Explicit variant.
638    #[test]
639    fn test_sort_entry_resolve_explicit() {
640        let explicit = Sort {
641            shorten_names: true,
642            render_substitutions: false,
643            template: vec![SortSpec {
644                key: SortKey::Title,
645                ascending: false,
646            }],
647        };
648        let entry = SortEntry::Explicit(explicit.clone());
649        let resolved = entry.resolve();
650
651        assert!(resolved.shorten_names);
652        assert!(!resolved.render_substitutions);
653        assert_eq!(resolved.template.len(), 1);
654        assert_eq!(resolved.template[0].key, SortKey::Title);
655        assert!(!resolved.template[0].ascending);
656    }
657}