Skip to main content

citum_engine/
sorting.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Reference sorting for bibliographies, groups, and citations.
7//!
8//! `ReferenceSorter` is the engine's single sorting stack: bibliography sorts
9//! (explicit `bibliography.sort`, processing presets, and config-level
10//! `sort:` mapped via `Sort::group_sort()`), per-group sorting, citation-item
11//! ordering, and disambiguation all route through it. Sort keys are compiled
12//! once and cached per reference (Schwartzian transform), so collation and
13//! article stripping are not re-derived on every comparison. Supports:
14//! - Type-order sorting (explicit sequence like [legal-case, statute, treaty])
15//! - Name-order sorting (family-given vs given-family for multilingual bibliographies)
16//! - Standard sort keys (author, title, issued) and an opt-in reference-ID tiebreak
17
18use std::collections::HashMap;
19
20use citum_schema::grouping::{GroupSort, GroupSortKey, NameSortOrder, SortKey as GroupSortKeyType};
21use citum_schema::locale::Locale;
22#[cfg(test)]
23use citum_schema::reference::ClassExtension;
24
25use crate::reference::Reference;
26use crate::sort_support::{TextCollator, author_sort_key_opt, normalize_sort_text, title_sort_key};
27
28fn compare_optional_years(a_year: Option<i32>, b_year: Option<i32>) -> std::cmp::Ordering {
29    match (a_year, b_year) {
30        (Some(a), Some(b)) => a.cmp(&b),
31        (Some(_), None) => std::cmp::Ordering::Less,
32        (None, Some(_)) => std::cmp::Ordering::Greater,
33        (None, None) => std::cmp::Ordering::Equal,
34    }
35}
36
37/// Sorts grouped bibliography entries using group-specific sort rules.
38pub struct ReferenceSorter<'a> {
39    locale: &'a Locale,
40    text_collator: TextCollator,
41}
42
43struct CachedReference<'a> {
44    reference: &'a Reference,
45    sort_values: Vec<CachedSortValue>,
46    /// Reference ID, cached once per reference so the ID tiebreak does not
47    /// re-derive it on every pairwise comparison. Populated only when sorting
48    /// via [`ReferenceSorter::sort_references_with_id_tiebreak`]; `None` otherwise
49    /// to keep ID-less sorts allocation-free.
50    id: Option<String>,
51}
52
53enum CachedSortValue {
54    RefType { name: String, rank: Option<usize> },
55    OptionalText(Option<String>),
56    Text(String),
57    Issued(Option<i32>),
58}
59
60enum CompiledSortKey<'a> {
61    RefType {
62        ascending: bool,
63        rank_by_type: Option<HashMap<String, usize>>,
64    },
65    Author {
66        ascending: bool,
67        name_order: NameSortOrder,
68    },
69    Title {
70        ascending: bool,
71    },
72    Issued {
73        ascending: bool,
74    },
75    Field {
76        ascending: bool,
77        field_name: &'a str,
78    },
79}
80
81impl<'a> ReferenceSorter<'a> {
82    /// Create a sorter that uses `locale` for locale-sensitive comparisons.
83    #[must_use]
84    pub fn new(locale: &'a Locale) -> Self {
85        Self {
86            locale,
87            text_collator: TextCollator::new(locale),
88        }
89    }
90
91    /// Sort references according to a group sort specification.
92    ///
93    /// Applies sort keys in order, with later keys acting as tiebreakers.
94    ///
95    /// # Arguments
96    ///
97    /// * `references` - References to sort
98    /// * `sort_spec` - Group sort specification
99    #[must_use]
100    pub fn sort_references<'b>(
101        &self,
102        references: Vec<&'b Reference>,
103        sort_spec: &GroupSort,
104    ) -> Vec<&'b Reference> {
105        self.sort_references_impl(references, sort_spec, false)
106    }
107
108    /// Sort references according to a group sort specification, breaking ties
109    /// between references whose keys compare equal by comparing reference IDs.
110    ///
111    /// References without an ID sort after references with one. The tiebreak
112    /// makes config-driven bibliography sorts fully deterministic, and is
113    /// opt-in because most `ReferenceSorter` call sites (grouping/render paths)
114    /// rely on stable-sort/registry order for equal keys instead.
115    ///
116    /// An empty sort template is a no-op: with no keys to compare, references
117    /// keep registry order rather than being reordered by ID alone.
118    #[must_use]
119    pub fn sort_references_with_id_tiebreak<'b>(
120        &self,
121        references: Vec<&'b Reference>,
122        sort_spec: &GroupSort,
123    ) -> Vec<&'b Reference> {
124        self.sort_references_impl(references, sort_spec, true)
125    }
126
127    fn sort_references_impl<'b>(
128        &self,
129        mut references: Vec<&'b Reference>,
130        sort_spec: &GroupSort,
131        id_tiebreak: bool,
132    ) -> Vec<&'b Reference> {
133        let compiled_keys = self.compile_sort_keys(sort_spec);
134        if compiled_keys.is_empty() {
135            return references;
136        }
137
138        let mut cached_references = references
139            .drain(..)
140            .map(|reference| CachedReference {
141                reference,
142                sort_values: compiled_keys
143                    .iter()
144                    .map(|sort_key| self.cache_sort_value(reference, sort_key))
145                    .collect(),
146                id: id_tiebreak.then(|| reference.id().map(|id| id.0)).flatten(),
147            })
148            .collect::<Vec<_>>();
149
150        cached_references.sort_by(|a, b| {
151            let cmp = self.compare_cached_references(a, b, &compiled_keys);
152            if cmp != std::cmp::Ordering::Equal || !id_tiebreak {
153                return cmp;
154            }
155            Self::compare_cached_ids(a, b)
156        });
157        cached_references
158            .into_iter()
159            .map(|entry| entry.reference)
160            .collect()
161    }
162
163    /// Deterministic tiebreaker: compare cached reference IDs as `&str`.
164    ///
165    /// `None` IDs sort last (missing ID > any present ID).
166    fn compare_cached_ids(a: &CachedReference<'_>, b: &CachedReference<'_>) -> std::cmp::Ordering {
167        match (&a.id, &b.id) {
168            (Some(a_id), Some(b_id)) => a_id.as_str().cmp(b_id.as_str()),
169            (Some(_), None) => std::cmp::Ordering::Less,
170            (None, Some(_)) => std::cmp::Ordering::Greater,
171            (None, None) => std::cmp::Ordering::Equal,
172        }
173    }
174
175    /// Compare two references by a single sort key.
176    #[must_use]
177    pub fn compare_by_key(
178        &self,
179        a: &Reference,
180        b: &Reference,
181        sort_key: &GroupSortKey,
182    ) -> std::cmp::Ordering {
183        self.compare_by_key_with_context(a, b, sort_key)
184    }
185
186    fn compare_by_key_with_context(
187        &self,
188        a: &Reference,
189        b: &Reference,
190        sort_key: &GroupSortKey,
191    ) -> std::cmp::Ordering {
192        let cmp = match &sort_key.key {
193            GroupSortKeyType::RefType => sort_key.order.as_ref().map_or_else(
194                || a.ref_type().cmp(&b.ref_type()),
195                |order| Self::compare_by_type_order(a, b, order),
196            ),
197            GroupSortKeyType::Author => sort_key.sort_order.as_ref().map_or_else(
198                || self.compare_by_author_with_order(a, b, NameSortOrder::FamilyGiven),
199                |name_order| self.compare_by_author_with_order(a, b, *name_order),
200            ),
201            GroupSortKeyType::Title => self.compare_by_title(a, b),
202            GroupSortKeyType::Issued => Self::compare_by_issued(a, b),
203            GroupSortKeyType::Field(field_name) => Self::compare_by_field(a, b, field_name),
204        };
205
206        if sort_key.ascending {
207            cmp
208        } else {
209            cmp.reverse()
210        }
211    }
212
213    /// Compare by type using explicit order sequence.
214    ///
215    /// Types appear in the order specified, regardless of alphabetical content.
216    /// Types not in the order list sort after those in the list, alphabetically.
217    fn compare_by_type_order(a: &Reference, b: &Reference, order: &[String]) -> std::cmp::Ordering {
218        let a_type = a.ref_type();
219        let b_type = b.ref_type();
220
221        let a_pos = order.iter().position(|t| t == &a_type);
222        let b_pos = order.iter().position(|t| t == &b_type);
223
224        match (a_pos, b_pos) {
225            (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
226            (Some(_), None) => std::cmp::Ordering::Less, // a in order, b not
227            (None, Some(_)) => std::cmp::Ordering::Greater, // b in order, a not
228            (None, None) => a_type.cmp(&b_type),         // both not in order, alphabetical
229        }
230    }
231
232    fn compile_sort_keys<'b>(&self, sort_spec: &'b GroupSort) -> Vec<CompiledSortKey<'b>> {
233        sort_spec
234            .template
235            .iter()
236            .map(|sort_key| match &sort_key.key {
237                GroupSortKeyType::RefType => CompiledSortKey::RefType {
238                    ascending: sort_key.ascending,
239                    rank_by_type: sort_key.order.as_ref().map(|order| {
240                        order
241                            .iter()
242                            .enumerate()
243                            .map(|(index, ref_type)| (ref_type.clone(), index))
244                            .collect()
245                    }),
246                },
247                GroupSortKeyType::Author => CompiledSortKey::Author {
248                    ascending: sort_key.ascending,
249                    name_order: sort_key.sort_order.unwrap_or(NameSortOrder::FamilyGiven),
250                },
251                GroupSortKeyType::Title => CompiledSortKey::Title {
252                    ascending: sort_key.ascending,
253                },
254                GroupSortKeyType::Issued => CompiledSortKey::Issued {
255                    ascending: sort_key.ascending,
256                },
257                GroupSortKeyType::Field(field_name) => CompiledSortKey::Field {
258                    ascending: sort_key.ascending,
259                    field_name,
260                },
261            })
262            .collect()
263    }
264
265    fn cache_sort_value(
266        &self,
267        reference: &Reference,
268        sort_key: &CompiledSortKey<'_>,
269    ) -> CachedSortValue {
270        match sort_key {
271            CompiledSortKey::RefType { rank_by_type, .. } => {
272                let ref_type = reference.ref_type();
273                CachedSortValue::RefType {
274                    name: ref_type.clone(),
275                    rank: rank_by_type
276                        .as_ref()
277                        .and_then(|ranks| ranks.get(&ref_type).copied()),
278                }
279            }
280            CompiledSortKey::Author { name_order, .. } => CachedSortValue::OptionalText(
281                self.extract_author_sort_key_opt(reference, *name_order),
282            ),
283            CompiledSortKey::Title { .. } => CachedSortValue::Text(self.title_sort_key(reference)),
284            CompiledSortKey::Issued { .. } => CachedSortValue::Issued(Self::issued_year(reference)),
285            CompiledSortKey::Field { field_name, .. } => {
286                CachedSortValue::Text(Self::field_sort_value(reference, field_name))
287            }
288        }
289    }
290
291    fn compare_cached_references(
292        &self,
293        a: &CachedReference<'_>,
294        b: &CachedReference<'_>,
295        compiled_keys: &[CompiledSortKey<'_>],
296    ) -> std::cmp::Ordering {
297        for (index, sort_key) in compiled_keys.iter().enumerate() {
298            #[allow(
299                clippy::indexing_slicing,
300                reason = "index is derived from compiled_keys"
301            )]
302            let cmp = self.compare_cached_value(&a.sort_values[index], &b.sort_values[index]);
303            let cmp = if Self::is_ascending(sort_key) {
304                cmp
305            } else {
306                cmp.reverse()
307            };
308
309            if cmp != std::cmp::Ordering::Equal {
310                return cmp;
311            }
312        }
313
314        std::cmp::Ordering::Equal
315    }
316
317    fn compare_cached_value(&self, a: &CachedSortValue, b: &CachedSortValue) -> std::cmp::Ordering {
318        match (a, b) {
319            (
320                CachedSortValue::RefType {
321                    name: a_name,
322                    rank: a_rank,
323                },
324                CachedSortValue::RefType {
325                    name: b_name,
326                    rank: b_rank,
327                },
328            ) => match (a_rank, b_rank) {
329                (Some(a_idx), Some(b_idx)) => a_idx.cmp(b_idx),
330                (Some(_), None) => std::cmp::Ordering::Less,
331                (None, Some(_)) => std::cmp::Ordering::Greater,
332                (None, None) => a_name.cmp(b_name),
333            },
334            (CachedSortValue::OptionalText(a_text), CachedSortValue::OptionalText(b_text)) => {
335                match (a_text, b_text) {
336                    (Some(a_value), Some(b_value)) => self.text_collator.compare(a_value, b_value),
337                    (Some(_), None) => std::cmp::Ordering::Less,
338                    (None, Some(_)) => std::cmp::Ordering::Greater,
339                    (None, None) => std::cmp::Ordering::Equal,
340                }
341            }
342            (CachedSortValue::Text(a_text), CachedSortValue::Text(b_text)) => {
343                self.text_collator.compare(a_text, b_text)
344            }
345            (CachedSortValue::Issued(a_year), CachedSortValue::Issued(b_year)) => {
346                compare_optional_years(*a_year, *b_year)
347            }
348            _ => std::cmp::Ordering::Equal,
349        }
350    }
351
352    fn is_ascending(sort_key: &CompiledSortKey<'_>) -> bool {
353        match sort_key {
354            CompiledSortKey::RefType { ascending, .. }
355            | CompiledSortKey::Author { ascending, .. }
356            | CompiledSortKey::Title { ascending }
357            | CompiledSortKey::Issued { ascending }
358            | CompiledSortKey::Field { ascending, .. } => *ascending,
359        }
360    }
361
362    /// Compare by author with culturally appropriate name ordering.
363    fn compare_by_author_with_order(
364        &self,
365        a: &Reference,
366        b: &Reference,
367        name_order: NameSortOrder,
368    ) -> std::cmp::Ordering {
369        let a_key = self.extract_author_sort_key_opt(a, name_order);
370        let b_key = self.extract_author_sort_key_opt(b, name_order);
371        match (a_key, b_key) {
372            (Some(a), Some(b)) => self.text_collator.compare(&a, &b),
373            (Some(_), None) => std::cmp::Ordering::Less,
374            (None, Some(_)) => std::cmp::Ordering::Greater,
375            (None, None) => std::cmp::Ordering::Equal,
376        }
377    }
378
379    /// Extract author sort key with specified name ordering.
380    ///
381    /// Unlike generic bibliography sorting, author-key sorting follows CSL
382    /// semantics for name keys: items without author/editor names are treated
383    /// as missing-name entries and sort after named entries.
384    fn extract_author_sort_key_opt(
385        &self,
386        reference: &Reference,
387        name_order: NameSortOrder,
388    ) -> Option<String> {
389        author_sort_key_opt(reference, name_order, self.locale, true)
390    }
391
392    /// Public helper retained for tests/debugging.
393    #[must_use]
394    pub fn extract_author_sort_key(
395        &self,
396        reference: &Reference,
397        name_order: NameSortOrder,
398    ) -> String {
399        self.extract_author_sort_key_opt(reference, name_order)
400            .unwrap_or_default()
401    }
402
403    /// Compare by title (with article stripping).
404    fn compare_by_title(&self, a: &Reference, b: &Reference) -> std::cmp::Ordering {
405        let a_title = self.title_sort_key(a);
406        let b_title = self.title_sort_key(b);
407        self.text_collator.compare(&a_title, &b_title)
408    }
409
410    /// Compare by issued date.
411    fn compare_by_issued(a: &Reference, b: &Reference) -> std::cmp::Ordering {
412        let a_year = Self::issued_year(a);
413        let b_year = Self::issued_year(b);
414        compare_optional_years(a_year, b_year)
415    }
416
417    /// Compare by custom field.
418    fn compare_by_field(a: &Reference, b: &Reference, field_name: &str) -> std::cmp::Ordering {
419        Self::field_sort_value(a, field_name).cmp(&Self::field_sort_value(b, field_name))
420    }
421
422    fn title_sort_key(&self, reference: &Reference) -> String {
423        title_sort_key(reference, self.locale)
424    }
425
426    fn issued_year(reference: &Reference) -> Option<i32> {
427        reference
428            .effective_issued_date()
429            .and_then(|d| d.year().parse::<i32>().ok())
430            .filter(|year| *year != 0)
431    }
432
433    fn field_sort_value(reference: &Reference, field_name: &str) -> String {
434        match field_name {
435            "language" => normalize_sort_text(reference.language().unwrap_or_default().as_ref()),
436            // Future: support for keywords, custom metadata
437            _ => String::new(),
438        }
439    }
440}
441
442#[cfg(test)]
443#[allow(
444    clippy::unwrap_used,
445    clippy::expect_used,
446    clippy::panic,
447    clippy::indexing_slicing,
448    clippy::todo,
449    clippy::unimplemented,
450    clippy::unreachable,
451    clippy::get_unwrap,
452    reason = "Panicking is acceptable and often desired in tests."
453)]
454mod tests {
455    use super::*;
456    use citum_schema::grouping::GroupSortKey;
457
458    fn make_locale() -> Locale {
459        Locale::en_us()
460    }
461
462    fn make_reference(
463        id: &str,
464        ref_type: &str,
465        author_family: &str,
466        title: &str,
467        year: i32,
468    ) -> Reference {
469        let json = serde_json::json!({
470            "id": id,
471            "type": ref_type,
472            "author": [{"family": author_family, "given": "Test"}],
473            "issued": {"date-parts": [[year]]},
474            "title": title,
475            "container-title": "Test Container",
476        });
477        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
478        legacy.into()
479    }
480
481    fn make_reference_no_author(id: &str, ref_type: &str, title: &str, year: i32) -> Reference {
482        let json = serde_json::json!({
483            "id": id,
484            "type": ref_type,
485            "issued": {"date-parts": [[year]]},
486            "title": title,
487            "container-title": "Test Container",
488        });
489        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
490        legacy.into()
491    }
492
493    #[test]
494    fn test_type_order_sorting() {
495        let locale = make_locale();
496        let sorter = ReferenceSorter::new(&locale);
497
498        // Use standard CSL JSON types for testing
499        let journal = make_reference("r1", "article-journal", "Smith", "Title J", 1990);
500        let magazine = make_reference("r2", "article-magazine", "Jones", "Title M", 2000);
501        let newspaper = make_reference("r3", "article-newspaper", "Brown", "Title N", 1985);
502        let book = make_reference("r4", "book", "Davis", "Title B", 1995);
503
504        let mut refs = vec![&book, &newspaper, &journal, &magazine];
505
506        let sort_spec = GroupSort {
507            template: vec![GroupSortKey {
508                key: GroupSortKeyType::RefType,
509                ascending: true,
510                order: Some(vec![
511                    "article-journal".to_string(),
512                    "article-magazine".to_string(),
513                    "article-newspaper".to_string(),
514                ]),
515                sort_order: None,
516            }],
517        };
518
519        refs = sorter.sort_references(refs, &sort_spec);
520
521        // Should be: article-journal, article-magazine, article-newspaper, then book (alphabetically after)
522        assert_eq!(refs[0].id().unwrap(), "r1"); // article-journal
523        assert_eq!(refs[1].id().unwrap(), "r2"); // article-magazine
524        assert_eq!(refs[2].id().unwrap(), "r3"); // article-newspaper
525        assert_eq!(refs[3].id().unwrap(), "r4"); // book
526    }
527
528    #[test]
529    fn test_author_family_given_order() {
530        let locale = make_locale();
531        let sorter = ReferenceSorter::new(&locale);
532
533        let smith = make_reference("r1", "book", "Smith", "Title", 2000);
534        let jones = make_reference("r2", "book", "Jones", "Title", 2000);
535        let brown = make_reference("r3", "book", "Brown", "Title", 2000);
536
537        let mut refs = vec![&smith, &jones, &brown];
538
539        let sort_spec = GroupSort {
540            template: vec![GroupSortKey {
541                key: GroupSortKeyType::Author,
542                ascending: true,
543                order: None,
544                sort_order: Some(NameSortOrder::FamilyGiven),
545            }],
546        };
547
548        refs = sorter.sort_references(refs, &sort_spec);
549
550        // Should be alphabetical by family name
551        assert_eq!(refs[0].id().unwrap(), "r3"); // Brown
552        assert_eq!(refs[1].id().unwrap(), "r2"); // Jones
553        assert_eq!(refs[2].id().unwrap(), "r1"); // Smith
554    }
555
556    #[test]
557    #[cfg(feature = "icu")]
558    fn test_author_sort_uses_unicode_collation_for_accented_names() {
559        let locale = make_locale();
560        let sorter = ReferenceSorter::new(&locale);
561
562        let celik = make_reference("r1", "book", "Çelik", "Title", 2000);
563        let zimring = make_reference("r2", "book", "Zimring", "Title", 2000);
564        let o_tuathail = make_reference("r3", "book", "Ó Tuathail", "Title", 2000);
565
566        let mut refs = vec![&o_tuathail, &zimring, &celik];
567
568        let sort_spec = GroupSort {
569            template: vec![GroupSortKey {
570                key: GroupSortKeyType::Author,
571                ascending: true,
572                order: None,
573                sort_order: Some(NameSortOrder::FamilyGiven),
574            }],
575        };
576
577        refs = sorter.sort_references(refs, &sort_spec);
578
579        assert_eq!(refs[0].id().unwrap(), "r1");
580        assert_eq!(refs[1].id().unwrap(), "r3");
581        assert_eq!(refs[2].id().unwrap(), "r2");
582    }
583
584    #[test]
585    #[cfg(feature = "icu")]
586    fn test_title_sort_uses_unicode_collation_for_accented_titles() {
587        let locale = make_locale();
588        let sorter = ReferenceSorter::new(&locale);
589
590        let accent = make_reference_no_author("r1", "book", "Órbitas del sur", 2000);
591        let plain = make_reference_no_author("r2", "book", "Origins of Theory", 2000);
592        let zeta = make_reference_no_author("r3", "book", "Zebra Studies", 2000);
593
594        let mut refs = vec![&zeta, &plain, &accent];
595
596        let sort_spec = GroupSort {
597            template: vec![GroupSortKey {
598                key: GroupSortKeyType::Title,
599                ascending: true,
600                order: None,
601                sort_order: None,
602            }],
603        };
604
605        refs = sorter.sort_references(refs, &sort_spec);
606
607        assert_eq!(refs[0].id().unwrap(), "r1");
608        assert_eq!(refs[1].id().unwrap(), "r2");
609        assert_eq!(refs[2].id().unwrap(), "r3");
610    }
611
612    #[test]
613    fn test_issued_descending() {
614        let locale = make_locale();
615        let sorter = ReferenceSorter::new(&locale);
616
617        let old = make_reference("r1", "book", "Smith", "Title", 1990);
618        let new = make_reference("r2", "book", "Jones", "Title", 2020);
619        let mid = make_reference("r3", "book", "Brown", "Title", 2005);
620
621        let mut refs = vec![&old, &new, &mid];
622
623        let sort_spec = GroupSort {
624            template: vec![GroupSortKey {
625                key: GroupSortKeyType::Issued,
626                ascending: false, // Descending
627                order: None,
628                sort_order: None,
629            }],
630        };
631
632        refs = sorter.sort_references(refs, &sort_spec);
633
634        // Should be newest first
635        assert_eq!(refs[0].id().unwrap(), "r2"); // 2020
636        assert_eq!(refs[1].id().unwrap(), "r3"); // 2005
637        assert_eq!(refs[2].id().unwrap(), "r1"); // 1990
638    }
639
640    #[test]
641    fn test_issued_ascending_places_undated_last() {
642        let locale = make_locale();
643        let sorter = ReferenceSorter::new(&locale);
644
645        let dated_early = make_reference("r1", "book", "Smith", "Book D", 1999);
646        let dated_late = make_reference("r2", "book", "Jones", "Book B", 2000);
647        let mut undated = make_reference("r3", "book", "Brown", "Book A", 2000);
648        if let ClassExtension::Monograph(monograph) = undated.extension_mut() {
649            monograph.issued = citum_schema::reference::EdtfString(String::new());
650        }
651
652        let mut refs = vec![&undated, &dated_late, &dated_early];
653
654        let sort_spec = GroupSort {
655            template: vec![GroupSortKey {
656                key: GroupSortKeyType::Issued,
657                ascending: true,
658                order: None,
659                sort_order: None,
660            }],
661        };
662
663        refs = sorter.sort_references(refs, &sort_spec);
664
665        assert_eq!(refs[0].id().unwrap(), "r1");
666        assert_eq!(refs[1].id().unwrap(), "r2");
667        assert_eq!(refs[2].id().unwrap(), "r3");
668    }
669
670    #[test]
671    fn test_issued_sort_uses_created_when_issued_is_missing() {
672        let locale = make_locale();
673        let sorter = ReferenceSorter::new(&locale);
674
675        let dated = make_reference("r1", "book", "Smith", "Book D", 1999);
676        let mut created_only = make_reference("r2", "book", "Jones", "Book C", 2000);
677        if let ClassExtension::Monograph(monograph) = created_only.extension_mut() {
678            monograph.created = citum_schema::reference::EdtfString("1985".to_string());
679            monograph.issued = citum_schema::reference::EdtfString(String::new());
680        }
681
682        let mut refs = vec![&dated, &created_only];
683
684        let sort_spec = GroupSort {
685            template: vec![GroupSortKey {
686                key: GroupSortKeyType::Issued,
687                ascending: true,
688                order: None,
689                sort_order: None,
690            }],
691        };
692
693        refs = sorter.sort_references(refs, &sort_spec);
694
695        assert_eq!(refs[0].id().unwrap(), "r2");
696        assert_eq!(refs[1].id().unwrap(), "r1");
697    }
698
699    #[test]
700    fn test_composite_sort() {
701        let locale = make_locale();
702        let sorter = ReferenceSorter::new(&locale);
703
704        let smith2020 = make_reference("r1", "book", "Smith", "Title", 2020);
705        let smith2010 = make_reference("r2", "book", "Smith", "Title", 2010);
706        let jones2020 = make_reference("r3", "book", "Jones", "Title", 2020);
707
708        let mut refs = vec![&smith2020, &jones2020, &smith2010];
709
710        let sort_spec = GroupSort {
711            template: vec![
712                GroupSortKey {
713                    key: GroupSortKeyType::Author,
714                    ascending: true,
715                    order: None,
716                    sort_order: Some(NameSortOrder::FamilyGiven),
717                },
718                GroupSortKey {
719                    key: GroupSortKeyType::Issued,
720                    ascending: false, // Descending within author
721                    order: None,
722                    sort_order: None,
723                },
724            ],
725        };
726
727        refs = sorter.sort_references(refs, &sort_spec);
728
729        // Should be: Jones 2020, then Smith 2020, then Smith 2010
730        assert_eq!(refs[0].id().unwrap(), "r3"); // Jones 2020
731        assert_eq!(refs[1].id().unwrap(), "r1"); // Smith 2020
732        assert_eq!(refs[2].id().unwrap(), "r2"); // Smith 2010
733    }
734
735    #[test]
736    fn test_author_sort_falls_back_to_title_for_missing_names() {
737        let locale = make_locale();
738        let sorter = ReferenceSorter::new(&locale);
739
740        let no_author = make_reference_no_author("r1", "legal-case", "Brown v. Board", 1954);
741        let brown = make_reference("r2", "book", "Brown", "Title", 2000);
742        let smith = make_reference("r3", "book", "Smith", "Title", 2000);
743
744        let mut refs = vec![&no_author, &smith, &brown];
745
746        let sort_spec = GroupSort {
747            template: vec![GroupSortKey {
748                key: GroupSortKeyType::Author,
749                ascending: true,
750                order: None,
751                sort_order: Some(NameSortOrder::FamilyGiven),
752            }],
753        };
754
755        refs = sorter.sort_references(refs, &sort_spec);
756
757        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown
758        assert_eq!(refs[1].id().unwrap(), "r1"); // Brown v. Board
759        assert_eq!(refs[2].id().unwrap(), "r3"); // Smith
760    }
761
762    #[test]
763    fn test_legal_citation_sort() {
764        let locale = make_locale();
765        let sorter = ReferenceSorter::new(&locale);
766
767        let case_a = make_reference("r1", "legal-case", "", "Doe v. Smith", 1990);
768        let case_b = make_reference("r2", "legal-case", "", "Brown v. Board", 1954);
769
770        let mut refs = vec![&case_a, &case_b];
771
772        let sort_spec = GroupSort {
773            template: vec![
774                GroupSortKey {
775                    key: GroupSortKeyType::Title, // Case name
776                    ascending: true,
777                    order: None,
778                    sort_order: None,
779                },
780                GroupSortKey {
781                    key: GroupSortKeyType::Issued,
782                    ascending: true,
783                    order: None,
784                    sort_order: None,
785                },
786            ],
787        };
788
789        refs = sorter.sort_references(refs, &sort_spec);
790        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown v. Board
791    }
792
793    #[test]
794    fn test_legal_hierarchy_sort() {
795        let locale = make_locale();
796        let sorter = ReferenceSorter::new(&locale);
797
798        let statute = make_reference("r1", "statute", "", "Clean Air Act", 1970);
799        let case = make_reference("r2", "legal-case", "", "Roe v. Wade", 1973);
800        let treaty = make_reference("r3", "treaty", "", "Paris Agreement", 2015);
801
802        let mut refs = vec![&treaty, &case, &statute];
803
804        let sort_spec = GroupSort {
805            template: vec![GroupSortKey {
806                key: GroupSortKeyType::RefType,
807                ascending: true,
808                order: Some(vec![
809                    "legal-case".to_string(),
810                    "statute".to_string(),
811                    "treaty".to_string(),
812                ]),
813                sort_order: None,
814            }],
815        };
816
817        refs = sorter.sort_references(refs, &sort_spec);
818
819        // Hierarchy: case, statute, treaty
820        assert_eq!(refs[0].id().unwrap(), "r2");
821        assert_eq!(refs[1].id().unwrap(), "r1");
822        assert_eq!(refs[2].id().unwrap(), "r3");
823    }
824
825    /// Given references whose sort keys are all equal, when sorted with the
826    /// ID tiebreak, then they come out in ID order.
827    #[test]
828    fn test_id_tiebreak_orders_equal_keys_by_id() {
829        let locale = make_locale();
830        let sorter = ReferenceSorter::new(&locale);
831
832        let c = make_reference("r-c", "book", "Smith", "Same Title", 2000);
833        let a = make_reference("r-a", "book", "Smith", "Same Title", 2000);
834        let b = make_reference("r-b", "book", "Smith", "Same Title", 2000);
835
836        let refs = vec![&c, &a, &b];
837
838        let sort_spec = GroupSort {
839            template: vec![GroupSortKey {
840                key: GroupSortKeyType::Author,
841                ascending: true,
842                order: None,
843                sort_order: Some(NameSortOrder::FamilyGiven),
844            }],
845        };
846
847        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
848
849        assert_eq!(sorted[0].id().unwrap(), "r-a");
850        assert_eq!(sorted[1].id().unwrap(), "r-b");
851        assert_eq!(sorted[2].id().unwrap(), "r-c");
852    }
853
854    /// Given one reference with no ID and one with equal sort keys, when
855    /// sorted with the ID tiebreak, then the ID-less reference sorts last.
856    #[test]
857    fn test_id_tiebreak_places_missing_id_last() {
858        let locale = make_locale();
859        let sorter = ReferenceSorter::new(&locale);
860
861        let with_id = make_reference("r1", "book", "Smith", "Same Title", 2000);
862        let mut no_id = make_reference("r2", "book", "Smith", "Same Title", 2000);
863        if let ClassExtension::Monograph(monograph) = no_id.extension_mut() {
864            monograph.id = None;
865        }
866
867        let refs = vec![&with_id, &no_id];
868
869        let sort_spec = GroupSort {
870            template: vec![GroupSortKey {
871                key: GroupSortKeyType::Author,
872                ascending: true,
873                order: None,
874                sort_order: Some(NameSortOrder::FamilyGiven),
875            }],
876        };
877
878        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
879
880        assert_eq!(sorted[0].id().unwrap(), "r1");
881        assert!(sorted[1].id().is_none());
882    }
883
884    /// Given an empty sort template, when sorted with the ID tiebreak, then
885    /// references keep registry order instead of being reordered by ID alone.
886    #[test]
887    fn test_id_tiebreak_with_empty_template_keeps_registry_order() {
888        let locale = make_locale();
889        let sorter = ReferenceSorter::new(&locale);
890
891        let c = make_reference("r-c", "book", "Smith", "Title C", 2000);
892        let a = make_reference("r-a", "book", "Jones", "Title A", 2001);
893
894        let refs = vec![&c, &a];
895        let sort_spec = GroupSort { template: vec![] };
896
897        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
898
899        assert_eq!(sorted[0].id().unwrap(), "r-c");
900        assert_eq!(sorted[1].id().unwrap(), "r-a");
901    }
902}