Skip to main content

citum_engine/grouping/
sorting.rs

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