Skip to main content

citum_engine/processor/
sorting.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Sorting logic for bibliography and citation entries.
7//!
8//! This module handles multi-key sorting of references according to style instructions,
9//! including support for anonymous works and article stripping for title-based sorting.
10
11use crate::reference::Reference;
12use crate::sort_support::{TextCollator, author_sort_key_opt, title_sort_key};
13use citum_schema::grouping::NameSortOrder;
14use citum_schema::locale::Locale;
15use citum_schema::options::{Config, SortKey};
16
17/// Compares two optional years with configurable ascending/descending order.
18///
19/// When both years are present, compares them numerically. If only one is present,
20/// the year-bearing reference comes before the year-less reference. If neither is present,
21/// they are considered equal.
22fn compare_optional_years(
23    a_year: Option<i32>,
24    b_year: Option<i32>,
25    ascending: bool,
26) -> std::cmp::Ordering {
27    let cmp = match (a_year, b_year) {
28        (Some(a), Some(b)) => a.cmp(&b),
29        (Some(_), None) => std::cmp::Ordering::Less,
30        (None, Some(_)) => std::cmp::Ordering::Greater,
31        (None, None) => std::cmp::Ordering::Equal,
32    };
33
34    if ascending { cmp } else { cmp.reverse() }
35}
36
37/// Sorter for bibliography and citation entries.
38///
39/// Sorts references according to style instructions, handling multi-key sorting
40/// and special cases like anonymous works falling back from author to editor to title.
41pub struct Sorter<'a> {
42    /// The configuration containing sort specifications.
43    config: &'a Config,
44    /// The locale used for article stripping in title-based sorts.
45    locale: &'a Locale,
46    /// Locale-aware text comparator for author/title sort keys.
47    text_collator: TextCollator,
48}
49
50impl<'a> Sorter<'a> {
51    /// Creates a new `Sorter` instance.
52    #[must_use]
53    pub fn new(config: &'a Config, locale: &'a Locale) -> Self {
54        Self {
55            config,
56            locale,
57            text_collator: TextCollator::new(locale),
58        }
59    }
60
61    /// Sort references according to style instructions.
62    ///
63    /// This handles multi-key sorting based on the style's `SortSpec`. It includes
64    /// specific logic for handling anonymous works (falling back from author to editor
65    /// to title) and stripping articles for title-based sorting.
66    #[must_use]
67    pub fn sort_references<'b>(&self, references: Vec<&'b Reference>) -> Vec<&'b Reference> {
68        let mut refs = references;
69        let processing = self.config.processing.clone().unwrap_or_default();
70        let proc_config = processing.config();
71
72        if let Some(sort_config) = &proc_config.sort {
73            // Build a composite sort that handles all keys together
74            // Built-in processing defaults are bibliography-facing only; citation
75            // cluster ordering remains explicit at the citation spec level.
76            let resolved = sort_config.resolve();
77            refs.sort_by(|a, b| {
78                for sort in &resolved.template {
79                    let cmp = match sort.key {
80                        SortKey::Author => {
81                            let a_sort_key = author_sort_key_opt(
82                                a,
83                                NameSortOrder::FamilyGiven,
84                                self.locale,
85                                true,
86                            )
87                            .unwrap_or_default();
88                            let b_sort_key = author_sort_key_opt(
89                                b,
90                                NameSortOrder::FamilyGiven,
91                                self.locale,
92                                true,
93                            )
94                            .unwrap_or_default();
95
96                            if sort.ascending {
97                                self.text_collator.compare(&a_sort_key, &b_sort_key)
98                            } else {
99                                self.text_collator.compare(&b_sort_key, &a_sort_key)
100                            }
101                        }
102                        SortKey::Year => {
103                            let a_year = a
104                                .csl_issued_date()
105                                .and_then(|d| d.year().parse::<i32>().ok())
106                                .filter(|year| *year != 0);
107                            let b_year = b
108                                .csl_issued_date()
109                                .and_then(|d| d.year().parse::<i32>().ok())
110                                .filter(|year| *year != 0);
111
112                            compare_optional_years(a_year, b_year, sort.ascending)
113                        }
114                        SortKey::Title => {
115                            let a_title = title_sort_key(a, self.locale);
116                            let b_title = title_sort_key(b, self.locale);
117
118                            if sort.ascending {
119                                self.text_collator.compare(&a_title, &b_title)
120                            } else {
121                                self.text_collator.compare(&b_title, &a_title)
122                            }
123                        }
124                        SortKey::CitationNumber => std::cmp::Ordering::Equal,
125                        // Handle future SortKey variants (non_exhaustive)
126                        _ => std::cmp::Ordering::Equal,
127                    };
128
129                    // If this key produces a non-equal comparison, use it
130                    // Otherwise, continue to the next key
131                    if cmp != std::cmp::Ordering::Equal {
132                        return cmp;
133                    }
134                }
135
136                // Deterministic tiebreaker: compare entry IDs as &str when all sort keys are equal.
137                // None IDs sort last (missing ID > any present ID).
138                match (a.id(), b.id()) {
139                    (Some(a_id), Some(b_id)) => a_id.0.as_str().cmp(b_id.0.as_str()),
140                    (Some(_), None) => std::cmp::Ordering::Less,
141                    (None, Some(_)) => std::cmp::Ordering::Greater,
142                    (None, None) => std::cmp::Ordering::Equal,
143                }
144            });
145        }
146
147        refs
148    }
149}