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;
22use citum_schema::options::Config;
23#[cfg(test)]
24use citum_schema::reference::ClassExtension;
25
26use crate::reference::Reference;
27use crate::sort_support::{
28    SortKeyOptions, TextCollator, author_sort_key_opt_with_options, collator_locale_id,
29    normalize_sort_text, title_sort_key_with_options,
30};
31
32fn compare_optional_years(a_year: Option<i32>, b_year: Option<i32>) -> std::cmp::Ordering {
33    match (a_year, b_year) {
34        (Some(a), Some(b)) => a.cmp(&b),
35        (Some(_), None) => std::cmp::Ordering::Less,
36        (None, Some(_)) => std::cmp::Ordering::Greater,
37        (None, None) => std::cmp::Ordering::Equal,
38    }
39}
40
41/// Sorts grouped bibliography entries using group-specific sort rules.
42pub struct ReferenceSorter<'a> {
43    locale: &'a Locale,
44    text_collator: TextCollator,
45    sort_key_options: SortKeyOptions,
46}
47
48struct CachedReference<'a> {
49    reference: &'a Reference,
50    sort_values: Vec<CachedSortValue>,
51    /// Reference ID, cached once per reference so the ID tiebreak does not
52    /// re-derive it on every pairwise comparison. Populated only when sorting
53    /// via [`ReferenceSorter::sort_references_with_id_tiebreak`]; `None` otherwise
54    /// to keep ID-less sorts allocation-free.
55    id: Option<String>,
56}
57
58enum CachedSortValue {
59    RefType { name: String, rank: Option<usize> },
60    OptionalText(Option<String>),
61    Text(String),
62    Issued(Option<i32>),
63}
64
65enum CompiledSortKey<'a> {
66    RefType {
67        ascending: bool,
68        rank_by_type: Option<HashMap<String, usize>>,
69    },
70    Author {
71        ascending: bool,
72        name_order: NameSortOrder,
73    },
74    Title {
75        ascending: bool,
76    },
77    Issued {
78        ascending: bool,
79    },
80    Field {
81        ascending: bool,
82        field_name: &'a str,
83    },
84}
85
86impl<'a> ReferenceSorter<'a> {
87    /// Create a sorter that uses `locale` for locale-sensitive comparisons.
88    #[must_use]
89    pub fn new(locale: &'a Locale) -> Self {
90        Self {
91            locale,
92            text_collator: TextCollator::new(locale),
93            sort_key_options: SortKeyOptions::uniform(),
94        }
95    }
96
97    /// Create a bibliography sorter using the effective bibliography config.
98    #[must_use]
99    pub fn with_bibliography_config(locale: &'a Locale, config: &Config) -> Self {
100        Self {
101            locale,
102            text_collator: TextCollator::new_for_locale_id(collator_locale_id(locale, config)),
103            sort_key_options: SortKeyOptions::from_config(config),
104        }
105    }
106
107    /// Sort references according to a group sort specification.
108    ///
109    /// Applies sort keys in order, with later keys acting as tiebreakers.
110    ///
111    /// # Arguments
112    ///
113    /// * `references` - References to sort
114    /// * `sort_spec` - Group sort specification
115    #[must_use]
116    pub fn sort_references<'b>(
117        &self,
118        references: Vec<&'b Reference>,
119        sort_spec: &GroupSort,
120    ) -> Vec<&'b Reference> {
121        self.sort_references_impl(references, sort_spec, false)
122    }
123
124    /// Sort references according to a group sort specification, breaking ties
125    /// between references whose keys compare equal by comparing reference IDs.
126    ///
127    /// References without an ID sort after references with one. The tiebreak
128    /// makes config-driven bibliography sorts fully deterministic, and is
129    /// opt-in because most `ReferenceSorter` call sites (grouping/render paths)
130    /// rely on stable-sort/registry order for equal keys instead.
131    ///
132    /// An empty sort template is a no-op: with no keys to compare, references
133    /// keep registry order rather than being reordered by ID alone.
134    #[must_use]
135    pub fn sort_references_with_id_tiebreak<'b>(
136        &self,
137        references: Vec<&'b Reference>,
138        sort_spec: &GroupSort,
139    ) -> Vec<&'b Reference> {
140        self.sort_references_impl(references, sort_spec, true)
141    }
142
143    fn sort_references_impl<'b>(
144        &self,
145        mut references: Vec<&'b Reference>,
146        sort_spec: &GroupSort,
147        id_tiebreak: bool,
148    ) -> Vec<&'b Reference> {
149        let compiled_keys = self.compile_sort_keys(sort_spec);
150        if compiled_keys.is_empty() {
151            return references;
152        }
153
154        let mut cached_references = references
155            .drain(..)
156            .map(|reference| CachedReference {
157                reference,
158                sort_values: compiled_keys
159                    .iter()
160                    .map(|sort_key| self.cache_sort_value(reference, sort_key))
161                    .collect(),
162                id: id_tiebreak.then(|| reference.id().map(|id| id.0)).flatten(),
163            })
164            .collect::<Vec<_>>();
165
166        cached_references.sort_by(|a, b| {
167            let cmp = self.compare_cached_references(a, b, &compiled_keys);
168            if cmp != std::cmp::Ordering::Equal || !id_tiebreak {
169                return cmp;
170            }
171            Self::compare_cached_ids(a, b)
172        });
173        cached_references
174            .into_iter()
175            .map(|entry| entry.reference)
176            .collect()
177    }
178
179    /// Deterministic tiebreaker: compare cached reference IDs as `&str`.
180    ///
181    /// `None` IDs sort last (missing ID > any present ID).
182    fn compare_cached_ids(a: &CachedReference<'_>, b: &CachedReference<'_>) -> std::cmp::Ordering {
183        match (&a.id, &b.id) {
184            (Some(a_id), Some(b_id)) => a_id.as_str().cmp(b_id.as_str()),
185            (Some(_), None) => std::cmp::Ordering::Less,
186            (None, Some(_)) => std::cmp::Ordering::Greater,
187            (None, None) => std::cmp::Ordering::Equal,
188        }
189    }
190
191    /// Compare two references by a single sort key.
192    #[must_use]
193    pub fn compare_by_key(
194        &self,
195        a: &Reference,
196        b: &Reference,
197        sort_key: &GroupSortKey,
198    ) -> std::cmp::Ordering {
199        self.compare_by_key_with_context(a, b, sort_key)
200    }
201
202    fn compare_by_key_with_context(
203        &self,
204        a: &Reference,
205        b: &Reference,
206        sort_key: &GroupSortKey,
207    ) -> std::cmp::Ordering {
208        let cmp = match &sort_key.key {
209            GroupSortKeyType::RefType => sort_key.order.as_ref().map_or_else(
210                || a.ref_type().cmp(&b.ref_type()),
211                |order| Self::compare_by_type_order(a, b, order),
212            ),
213            GroupSortKeyType::Author => sort_key.sort_order.as_ref().map_or_else(
214                || self.compare_by_author_with_order(a, b, NameSortOrder::FamilyGiven),
215                |name_order| self.compare_by_author_with_order(a, b, *name_order),
216            ),
217            GroupSortKeyType::Title => self.compare_by_title(a, b),
218            GroupSortKeyType::Issued => Self::compare_by_issued(a, b),
219            GroupSortKeyType::Field(field_name) => Self::compare_by_field(a, b, field_name),
220        };
221
222        if sort_key.ascending {
223            cmp
224        } else {
225            cmp.reverse()
226        }
227    }
228
229    /// Compare by type using explicit order sequence.
230    ///
231    /// Types appear in the order specified, regardless of alphabetical content.
232    /// Types not in the order list sort after those in the list, alphabetically.
233    fn compare_by_type_order(a: &Reference, b: &Reference, order: &[String]) -> std::cmp::Ordering {
234        let a_type = a.ref_type();
235        let b_type = b.ref_type();
236
237        let a_pos = order.iter().position(|t| t == &a_type);
238        let b_pos = order.iter().position(|t| t == &b_type);
239
240        match (a_pos, b_pos) {
241            (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
242            (Some(_), None) => std::cmp::Ordering::Less, // a in order, b not
243            (None, Some(_)) => std::cmp::Ordering::Greater, // b in order, a not
244            (None, None) => a_type.cmp(&b_type),         // both not in order, alphabetical
245        }
246    }
247
248    fn compile_sort_keys<'b>(&self, sort_spec: &'b GroupSort) -> Vec<CompiledSortKey<'b>> {
249        sort_spec
250            .template
251            .iter()
252            .map(|sort_key| match &sort_key.key {
253                GroupSortKeyType::RefType => CompiledSortKey::RefType {
254                    ascending: sort_key.ascending,
255                    rank_by_type: sort_key.order.as_ref().map(|order| {
256                        order
257                            .iter()
258                            .enumerate()
259                            .map(|(index, ref_type)| (ref_type.clone(), index))
260                            .collect()
261                    }),
262                },
263                GroupSortKeyType::Author => CompiledSortKey::Author {
264                    ascending: sort_key.ascending,
265                    name_order: sort_key.sort_order.unwrap_or(NameSortOrder::FamilyGiven),
266                },
267                GroupSortKeyType::Title => CompiledSortKey::Title {
268                    ascending: sort_key.ascending,
269                },
270                GroupSortKeyType::Issued => CompiledSortKey::Issued {
271                    ascending: sort_key.ascending,
272                },
273                GroupSortKeyType::Field(field_name) => CompiledSortKey::Field {
274                    ascending: sort_key.ascending,
275                    field_name,
276                },
277            })
278            .collect()
279    }
280
281    fn cache_sort_value(
282        &self,
283        reference: &Reference,
284        sort_key: &CompiledSortKey<'_>,
285    ) -> CachedSortValue {
286        match sort_key {
287            CompiledSortKey::RefType { rank_by_type, .. } => {
288                let ref_type = reference.ref_type();
289                CachedSortValue::RefType {
290                    name: ref_type.clone(),
291                    rank: rank_by_type
292                        .as_ref()
293                        .and_then(|ranks| ranks.get(&ref_type).copied()),
294                }
295            }
296            CompiledSortKey::Author { name_order, .. } => CachedSortValue::OptionalText(
297                self.extract_author_sort_key_opt(reference, *name_order),
298            ),
299            CompiledSortKey::Title { .. } => CachedSortValue::Text(self.title_sort_key(reference)),
300            CompiledSortKey::Issued { .. } => CachedSortValue::Issued(Self::issued_year(reference)),
301            CompiledSortKey::Field { field_name, .. } => {
302                CachedSortValue::Text(Self::field_sort_value(reference, field_name))
303            }
304        }
305    }
306
307    fn compare_cached_references(
308        &self,
309        a: &CachedReference<'_>,
310        b: &CachedReference<'_>,
311        compiled_keys: &[CompiledSortKey<'_>],
312    ) -> std::cmp::Ordering {
313        for (index, sort_key) in compiled_keys.iter().enumerate() {
314            #[allow(
315                clippy::indexing_slicing,
316                reason = "index is derived from compiled_keys"
317            )]
318            let cmp = self.compare_cached_value(&a.sort_values[index], &b.sort_values[index]);
319            let cmp = if Self::is_ascending(sort_key) {
320                cmp
321            } else {
322                cmp.reverse()
323            };
324
325            if cmp != std::cmp::Ordering::Equal {
326                return cmp;
327            }
328        }
329
330        std::cmp::Ordering::Equal
331    }
332
333    fn compare_cached_value(&self, a: &CachedSortValue, b: &CachedSortValue) -> std::cmp::Ordering {
334        match (a, b) {
335            (
336                CachedSortValue::RefType {
337                    name: a_name,
338                    rank: a_rank,
339                },
340                CachedSortValue::RefType {
341                    name: b_name,
342                    rank: b_rank,
343                },
344            ) => match (a_rank, b_rank) {
345                (Some(a_idx), Some(b_idx)) => a_idx.cmp(b_idx),
346                (Some(_), None) => std::cmp::Ordering::Less,
347                (None, Some(_)) => std::cmp::Ordering::Greater,
348                (None, None) => a_name.cmp(b_name),
349            },
350            (CachedSortValue::OptionalText(a_text), CachedSortValue::OptionalText(b_text)) => {
351                match (a_text, b_text) {
352                    (Some(a_value), Some(b_value)) => self.text_collator.compare(a_value, b_value),
353                    (Some(_), None) => std::cmp::Ordering::Less,
354                    (None, Some(_)) => std::cmp::Ordering::Greater,
355                    (None, None) => std::cmp::Ordering::Equal,
356                }
357            }
358            (CachedSortValue::Text(a_text), CachedSortValue::Text(b_text)) => {
359                self.text_collator.compare(a_text, b_text)
360            }
361            (CachedSortValue::Issued(a_year), CachedSortValue::Issued(b_year)) => {
362                compare_optional_years(*a_year, *b_year)
363            }
364            _ => std::cmp::Ordering::Equal,
365        }
366    }
367
368    fn is_ascending(sort_key: &CompiledSortKey<'_>) -> bool {
369        match sort_key {
370            CompiledSortKey::RefType { ascending, .. }
371            | CompiledSortKey::Author { ascending, .. }
372            | CompiledSortKey::Title { ascending }
373            | CompiledSortKey::Issued { ascending }
374            | CompiledSortKey::Field { ascending, .. } => *ascending,
375        }
376    }
377
378    /// Compare by author with culturally appropriate name ordering.
379    fn compare_by_author_with_order(
380        &self,
381        a: &Reference,
382        b: &Reference,
383        name_order: NameSortOrder,
384    ) -> std::cmp::Ordering {
385        let a_key = self.extract_author_sort_key_opt(a, name_order);
386        let b_key = self.extract_author_sort_key_opt(b, name_order);
387        match (a_key, b_key) {
388            (Some(a), Some(b)) => self.text_collator.compare(&a, &b),
389            (Some(_), None) => std::cmp::Ordering::Less,
390            (None, Some(_)) => std::cmp::Ordering::Greater,
391            (None, None) => std::cmp::Ordering::Equal,
392        }
393    }
394
395    /// Extract author sort key with specified name ordering.
396    ///
397    /// Unlike generic bibliography sorting, author-key sorting follows CSL
398    /// semantics for name keys: items without author/editor names are treated
399    /// as missing-name entries and sort after named entries.
400    fn extract_author_sort_key_opt(
401        &self,
402        reference: &Reference,
403        name_order: NameSortOrder,
404    ) -> Option<String> {
405        author_sort_key_opt_with_options(
406            reference,
407            name_order,
408            self.locale,
409            true,
410            &self.sort_key_options,
411        )
412    }
413
414    /// Public helper retained for tests/debugging.
415    #[must_use]
416    pub fn extract_author_sort_key(
417        &self,
418        reference: &Reference,
419        name_order: NameSortOrder,
420    ) -> String {
421        self.extract_author_sort_key_opt(reference, name_order)
422            .unwrap_or_default()
423    }
424
425    /// Compare by title (with article stripping).
426    fn compare_by_title(&self, a: &Reference, b: &Reference) -> std::cmp::Ordering {
427        let a_title = self.title_sort_key(a);
428        let b_title = self.title_sort_key(b);
429        self.text_collator.compare(&a_title, &b_title)
430    }
431
432    /// Compare by issued date.
433    fn compare_by_issued(a: &Reference, b: &Reference) -> std::cmp::Ordering {
434        let a_year = Self::issued_year(a);
435        let b_year = Self::issued_year(b);
436        compare_optional_years(a_year, b_year)
437    }
438
439    /// Compare by custom field.
440    fn compare_by_field(a: &Reference, b: &Reference, field_name: &str) -> std::cmp::Ordering {
441        Self::field_sort_value(a, field_name).cmp(&Self::field_sort_value(b, field_name))
442    }
443
444    fn title_sort_key(&self, reference: &Reference) -> String {
445        title_sort_key_with_options(reference, self.locale, &self.sort_key_options)
446    }
447
448    fn issued_year(reference: &Reference) -> Option<i32> {
449        reference
450            .effective_issued_date()
451            .and_then(|d| d.year().parse::<i32>().ok())
452            .filter(|year| *year != 0)
453    }
454
455    fn field_sort_value(reference: &Reference, field_name: &str) -> String {
456        match field_name {
457            "language" => normalize_sort_text(reference.language().unwrap_or_default().as_ref()),
458            // Future: support for keywords, custom metadata
459            _ => String::new(),
460        }
461    }
462}
463
464#[cfg(test)]
465#[allow(
466    clippy::unwrap_used,
467    clippy::expect_used,
468    clippy::panic,
469    clippy::indexing_slicing,
470    clippy::todo,
471    clippy::unimplemented,
472    clippy::unreachable,
473    clippy::get_unwrap,
474    reason = "Panicking is acceptable and often desired in tests."
475)]
476mod tests {
477    use super::*;
478    use citum_schema::grouping::GroupSortKey;
479    use citum_schema::options::{MultilingualConfig, SortingConfig, SortingMultilingualMode};
480    use citum_schema::reference::contributor::MultilingualName;
481    use citum_schema::reference::types::MultilingualComplex;
482    use citum_schema::reference::{
483        Contributor, ContributorList, EdtfString, Monograph, MonographType, MultilingualString,
484        StructuredName, Title,
485    };
486    use std::collections::HashMap;
487
488    fn make_locale() -> Locale {
489        Locale::en_us()
490    }
491
492    fn make_reference(
493        id: &str,
494        ref_type: &str,
495        author_family: &str,
496        title: &str,
497        year: i32,
498    ) -> Reference {
499        let json = serde_json::json!({
500            "id": id,
501            "type": ref_type,
502            "author": [{"family": author_family, "given": "Test"}],
503            "issued": {"date-parts": [[year]]},
504            "title": title,
505            "container-title": "Test Container",
506        });
507        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
508        legacy.into()
509    }
510
511    fn make_reference_no_author(id: &str, ref_type: &str, title: &str, year: i32) -> Reference {
512        let json = serde_json::json!({
513            "id": id,
514            "type": ref_type,
515            "issued": {"date-parts": [[year]]},
516            "title": title,
517            "container-title": "Test Container",
518        });
519        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
520        legacy.into()
521    }
522
523    fn romanized_config() -> Config {
524        Config {
525            sorting: Some(SortingConfig {
526                multilingual: Some(SortingMultilingualMode::Romanized),
527                ..Default::default()
528            }),
529            multilingual: Some(MultilingualConfig {
530                preferred_transliteration: Some(vec!["ru-Latn-alalc97".to_string()]),
531                ..Default::default()
532            }),
533            ..Default::default()
534        }
535    }
536
537    fn multilingual_author_reference(
538        id: &str,
539        original_family: MultilingualString,
540        sort_as: Option<&str>,
541        transliteration_family: Option<&str>,
542        title: Title,
543    ) -> Reference {
544        let transliterations = transliteration_family.map_or_else(HashMap::new, |family| {
545            HashMap::from([(
546                "ru-Latn-alalc97".to_string(),
547                StructuredName {
548                    family: family.into(),
549                    given: "Lev".into(),
550                    ..Default::default()
551                },
552            )])
553        });
554
555        Reference::Monograph(Box::new(Monograph {
556            id: Some(id.into()),
557            r#type: MonographType::Book,
558            title: Some(title),
559            author: Some(Contributor::ContributorList(ContributorList(vec![
560                Contributor::Multilingual(MultilingualName {
561                    original: StructuredName {
562                        family: original_family,
563                        given: "Лев".into(),
564                        ..Default::default()
565                    },
566                    lang: Some("ru".into()),
567                    sort_as: sort_as.map(str::to_string),
568                    transliterations,
569                    translations: HashMap::new(),
570                }),
571            ]))),
572            issued: EdtfString("1869".to_string()),
573            ..Default::default()
574        }))
575    }
576
577    fn title_sort(sorter: &ReferenceSorter<'_>, references: Vec<&Reference>) -> Vec<String> {
578        let sort_spec = GroupSort {
579            template: vec![GroupSortKey {
580                key: GroupSortKeyType::Title,
581                ascending: true,
582                order: None,
583                sort_order: None,
584            }],
585        };
586
587        sorter
588            .sort_references(references, &sort_spec)
589            .into_iter()
590            .map(|reference| reference.id().expect("test reference id").to_string())
591            .collect()
592    }
593
594    #[test]
595    fn test_type_order_sorting() {
596        let locale = make_locale();
597        let sorter = ReferenceSorter::new(&locale);
598
599        // Use standard CSL JSON types for testing
600        let journal = make_reference("r1", "article-journal", "Smith", "Title J", 1990);
601        let magazine = make_reference("r2", "article-magazine", "Jones", "Title M", 2000);
602        let newspaper = make_reference("r3", "article-newspaper", "Brown", "Title N", 1985);
603        let book = make_reference("r4", "book", "Davis", "Title B", 1995);
604
605        let mut refs = vec![&book, &newspaper, &journal, &magazine];
606
607        let sort_spec = GroupSort {
608            template: vec![GroupSortKey {
609                key: GroupSortKeyType::RefType,
610                ascending: true,
611                order: Some(vec![
612                    "article-journal".to_string(),
613                    "article-magazine".to_string(),
614                    "article-newspaper".to_string(),
615                ]),
616                sort_order: None,
617            }],
618        };
619
620        refs = sorter.sort_references(refs, &sort_spec);
621
622        // Should be: article-journal, article-magazine, article-newspaper, then book (alphabetically after)
623        assert_eq!(refs[0].id().unwrap(), "r1"); // article-journal
624        assert_eq!(refs[1].id().unwrap(), "r2"); // article-magazine
625        assert_eq!(refs[2].id().unwrap(), "r3"); // article-newspaper
626        assert_eq!(refs[3].id().unwrap(), "r4"); // book
627    }
628
629    #[test]
630    fn test_author_family_given_order() {
631        let locale = make_locale();
632        let sorter = ReferenceSorter::new(&locale);
633
634        let smith = make_reference("r1", "book", "Smith", "Title", 2000);
635        let jones = make_reference("r2", "book", "Jones", "Title", 2000);
636        let brown = make_reference("r3", "book", "Brown", "Title", 2000);
637
638        let mut refs = vec![&smith, &jones, &brown];
639
640        let sort_spec = GroupSort {
641            template: vec![GroupSortKey {
642                key: GroupSortKeyType::Author,
643                ascending: true,
644                order: None,
645                sort_order: Some(NameSortOrder::FamilyGiven),
646            }],
647        };
648
649        refs = sorter.sort_references(refs, &sort_spec);
650
651        // Should be alphabetical by family name
652        assert_eq!(refs[0].id().unwrap(), "r3"); // Brown
653        assert_eq!(refs[1].id().unwrap(), "r2"); // Jones
654        assert_eq!(refs[2].id().unwrap(), "r1"); // Smith
655    }
656
657    #[test]
658    #[cfg(feature = "icu")]
659    fn test_author_sort_uses_unicode_collation_for_accented_names() {
660        let locale = make_locale();
661        let sorter = ReferenceSorter::new(&locale);
662
663        let celik = make_reference("r1", "book", "Çelik", "Title", 2000);
664        let zimring = make_reference("r2", "book", "Zimring", "Title", 2000);
665        let o_tuathail = make_reference("r3", "book", "Ó Tuathail", "Title", 2000);
666
667        let mut refs = vec![&o_tuathail, &zimring, &celik];
668
669        let sort_spec = GroupSort {
670            template: vec![GroupSortKey {
671                key: GroupSortKeyType::Author,
672                ascending: true,
673                order: None,
674                sort_order: Some(NameSortOrder::FamilyGiven),
675            }],
676        };
677
678        refs = sorter.sort_references(refs, &sort_spec);
679
680        assert_eq!(refs[0].id().unwrap(), "r1");
681        assert_eq!(refs[1].id().unwrap(), "r3");
682        assert_eq!(refs[2].id().unwrap(), "r2");
683    }
684
685    #[test]
686    #[cfg(feature = "icu")]
687    fn test_title_sort_uses_unicode_collation_for_accented_titles() {
688        let locale = make_locale();
689        let sorter = ReferenceSorter::new(&locale);
690
691        let accent = make_reference_no_author("r1", "book", "Órbitas del sur", 2000);
692        let plain = make_reference_no_author("r2", "book", "Origins of Theory", 2000);
693        let zeta = make_reference_no_author("r3", "book", "Zebra Studies", 2000);
694
695        let mut refs = vec![&zeta, &plain, &accent];
696
697        let sort_spec = GroupSort {
698            template: vec![GroupSortKey {
699                key: GroupSortKeyType::Title,
700                ascending: true,
701                order: None,
702                sort_order: None,
703            }],
704        };
705
706        refs = sorter.sort_references(refs, &sort_spec);
707
708        assert_eq!(refs[0].id().unwrap(), "r1");
709        assert_eq!(refs[1].id().unwrap(), "r2");
710        assert_eq!(refs[2].id().unwrap(), "r3");
711    }
712
713    #[test]
714    fn test_issued_descending() {
715        let locale = make_locale();
716        let sorter = ReferenceSorter::new(&locale);
717
718        let old = make_reference("r1", "book", "Smith", "Title", 1990);
719        let new = make_reference("r2", "book", "Jones", "Title", 2020);
720        let mid = make_reference("r3", "book", "Brown", "Title", 2005);
721
722        let mut refs = vec![&old, &new, &mid];
723
724        let sort_spec = GroupSort {
725            template: vec![GroupSortKey {
726                key: GroupSortKeyType::Issued,
727                ascending: false, // Descending
728                order: None,
729                sort_order: None,
730            }],
731        };
732
733        refs = sorter.sort_references(refs, &sort_spec);
734
735        // Should be newest first
736        assert_eq!(refs[0].id().unwrap(), "r2"); // 2020
737        assert_eq!(refs[1].id().unwrap(), "r3"); // 2005
738        assert_eq!(refs[2].id().unwrap(), "r1"); // 1990
739    }
740
741    #[test]
742    fn test_issued_ascending_places_undated_last() {
743        let locale = make_locale();
744        let sorter = ReferenceSorter::new(&locale);
745
746        let dated_early = make_reference("r1", "book", "Smith", "Book D", 1999);
747        let dated_late = make_reference("r2", "book", "Jones", "Book B", 2000);
748        let mut undated = make_reference("r3", "book", "Brown", "Book A", 2000);
749        if let ClassExtension::Monograph(monograph) = undated.extension_mut() {
750            monograph.issued = citum_schema::reference::EdtfString(String::new());
751        }
752
753        let mut refs = vec![&undated, &dated_late, &dated_early];
754
755        let sort_spec = GroupSort {
756            template: vec![GroupSortKey {
757                key: GroupSortKeyType::Issued,
758                ascending: true,
759                order: None,
760                sort_order: None,
761            }],
762        };
763
764        refs = sorter.sort_references(refs, &sort_spec);
765
766        assert_eq!(refs[0].id().unwrap(), "r1");
767        assert_eq!(refs[1].id().unwrap(), "r2");
768        assert_eq!(refs[2].id().unwrap(), "r3");
769    }
770
771    #[test]
772    fn test_issued_sort_uses_created_when_issued_is_missing() {
773        let locale = make_locale();
774        let sorter = ReferenceSorter::new(&locale);
775
776        let dated = make_reference("r1", "book", "Smith", "Book D", 1999);
777        let mut created_only = make_reference("r2", "book", "Jones", "Book C", 2000);
778        if let ClassExtension::Monograph(monograph) = created_only.extension_mut() {
779            monograph.created = citum_schema::reference::EdtfString("1985".to_string());
780            monograph.issued = citum_schema::reference::EdtfString(String::new());
781        }
782
783        let mut refs = vec![&dated, &created_only];
784
785        let sort_spec = GroupSort {
786            template: vec![GroupSortKey {
787                key: GroupSortKeyType::Issued,
788                ascending: true,
789                order: None,
790                sort_order: None,
791            }],
792        };
793
794        refs = sorter.sort_references(refs, &sort_spec);
795
796        assert_eq!(refs[0].id().unwrap(), "r2");
797        assert_eq!(refs[1].id().unwrap(), "r1");
798    }
799
800    #[test]
801    fn test_composite_sort() {
802        let locale = make_locale();
803        let sorter = ReferenceSorter::new(&locale);
804
805        let smith2020 = make_reference("r1", "book", "Smith", "Title", 2020);
806        let smith2010 = make_reference("r2", "book", "Smith", "Title", 2010);
807        let jones2020 = make_reference("r3", "book", "Jones", "Title", 2020);
808
809        let mut refs = vec![&smith2020, &jones2020, &smith2010];
810
811        let sort_spec = GroupSort {
812            template: vec![
813                GroupSortKey {
814                    key: GroupSortKeyType::Author,
815                    ascending: true,
816                    order: None,
817                    sort_order: Some(NameSortOrder::FamilyGiven),
818                },
819                GroupSortKey {
820                    key: GroupSortKeyType::Issued,
821                    ascending: false, // Descending within author
822                    order: None,
823                    sort_order: None,
824                },
825            ],
826        };
827
828        refs = sorter.sort_references(refs, &sort_spec);
829
830        // Should be: Jones 2020, then Smith 2020, then Smith 2010
831        assert_eq!(refs[0].id().unwrap(), "r3"); // Jones 2020
832        assert_eq!(refs[1].id().unwrap(), "r1"); // Smith 2020
833        assert_eq!(refs[2].id().unwrap(), "r2"); // Smith 2010
834    }
835
836    #[test]
837    fn test_author_sort_falls_back_to_title_for_missing_names() {
838        let locale = make_locale();
839        let sorter = ReferenceSorter::new(&locale);
840
841        let no_author = make_reference_no_author("r1", "legal-case", "Brown v. Board", 1954);
842        let brown = make_reference("r2", "book", "Brown", "Title", 2000);
843        let smith = make_reference("r3", "book", "Smith", "Title", 2000);
844
845        let mut refs = vec![&no_author, &smith, &brown];
846
847        let sort_spec = GroupSort {
848            template: vec![GroupSortKey {
849                key: GroupSortKeyType::Author,
850                ascending: true,
851                order: None,
852                sort_order: Some(NameSortOrder::FamilyGiven),
853            }],
854        };
855
856        refs = sorter.sort_references(refs, &sort_spec);
857
858        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown
859        assert_eq!(refs[1].id().unwrap(), "r1"); // Brown v. Board
860        assert_eq!(refs[2].id().unwrap(), "r3"); // Smith
861    }
862
863    #[test]
864    fn test_legal_citation_sort() {
865        let locale = make_locale();
866        let sorter = ReferenceSorter::new(&locale);
867
868        let case_a = make_reference("r1", "legal-case", "", "Doe v. Smith", 1990);
869        let case_b = make_reference("r2", "legal-case", "", "Brown v. Board", 1954);
870
871        let mut refs = vec![&case_a, &case_b];
872
873        let sort_spec = GroupSort {
874            template: vec![
875                GroupSortKey {
876                    key: GroupSortKeyType::Title, // Case name
877                    ascending: true,
878                    order: None,
879                    sort_order: None,
880                },
881                GroupSortKey {
882                    key: GroupSortKeyType::Issued,
883                    ascending: true,
884                    order: None,
885                    sort_order: None,
886                },
887            ],
888        };
889
890        refs = sorter.sort_references(refs, &sort_spec);
891        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown v. Board
892    }
893
894    #[test]
895    fn test_legal_hierarchy_sort() {
896        let locale = make_locale();
897        let sorter = ReferenceSorter::new(&locale);
898
899        let statute = make_reference("r1", "statute", "", "Clean Air Act", 1970);
900        let case = make_reference("r2", "legal-case", "", "Roe v. Wade", 1973);
901        let treaty = make_reference("r3", "treaty", "", "Paris Agreement", 2015);
902
903        let mut refs = vec![&treaty, &case, &statute];
904
905        let sort_spec = GroupSort {
906            template: vec![GroupSortKey {
907                key: GroupSortKeyType::RefType,
908                ascending: true,
909                order: Some(vec![
910                    "legal-case".to_string(),
911                    "statute".to_string(),
912                    "treaty".to_string(),
913                ]),
914                sort_order: None,
915            }],
916        };
917
918        refs = sorter.sort_references(refs, &sort_spec);
919
920        // Hierarchy: case, statute, treaty
921        assert_eq!(refs[0].id().unwrap(), "r2");
922        assert_eq!(refs[1].id().unwrap(), "r1");
923        assert_eq!(refs[2].id().unwrap(), "r3");
924    }
925
926    /// Given references whose sort keys are all equal, when sorted with the
927    /// ID tiebreak, then they come out in ID order.
928    #[test]
929    fn test_id_tiebreak_orders_equal_keys_by_id() {
930        let locale = make_locale();
931        let sorter = ReferenceSorter::new(&locale);
932
933        let c = make_reference("r-c", "book", "Smith", "Same Title", 2000);
934        let a = make_reference("r-a", "book", "Smith", "Same Title", 2000);
935        let b = make_reference("r-b", "book", "Smith", "Same Title", 2000);
936
937        let refs = vec![&c, &a, &b];
938
939        let sort_spec = GroupSort {
940            template: vec![GroupSortKey {
941                key: GroupSortKeyType::Author,
942                ascending: true,
943                order: None,
944                sort_order: Some(NameSortOrder::FamilyGiven),
945            }],
946        };
947
948        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
949
950        assert_eq!(sorted[0].id().unwrap(), "r-a");
951        assert_eq!(sorted[1].id().unwrap(), "r-b");
952        assert_eq!(sorted[2].id().unwrap(), "r-c");
953    }
954
955    /// Given one reference with no ID and one with equal sort keys, when
956    /// sorted with the ID tiebreak, then the ID-less reference sorts last.
957    #[test]
958    fn test_id_tiebreak_places_missing_id_last() {
959        let locale = make_locale();
960        let sorter = ReferenceSorter::new(&locale);
961
962        let with_id = make_reference("r1", "book", "Smith", "Same Title", 2000);
963        let mut no_id = make_reference("r2", "book", "Smith", "Same Title", 2000);
964        if let ClassExtension::Monograph(monograph) = no_id.extension_mut() {
965            monograph.id = None;
966        }
967
968        let refs = vec![&with_id, &no_id];
969
970        let sort_spec = GroupSort {
971            template: vec![GroupSortKey {
972                key: GroupSortKeyType::Author,
973                ascending: true,
974                order: None,
975                sort_order: Some(NameSortOrder::FamilyGiven),
976            }],
977        };
978
979        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
980
981        assert_eq!(sorted[0].id().unwrap(), "r1");
982        assert!(sorted[1].id().is_none());
983    }
984
985    /// Given an empty sort template, when sorted with the ID tiebreak, then
986    /// references keep registry order instead of being reordered by ID alone.
987    #[test]
988    fn test_id_tiebreak_with_empty_template_keeps_registry_order() {
989        let locale = make_locale();
990        let sorter = ReferenceSorter::new(&locale);
991
992        let c = make_reference("r-c", "book", "Smith", "Title C", 2000);
993        let a = make_reference("r-a", "book", "Jones", "Title A", 2001);
994
995        let refs = vec![&c, &a];
996        let sort_spec = GroupSort { template: vec![] };
997
998        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
999
1000        assert_eq!(sorted[0].id().unwrap(), "r-c");
1001        assert_eq!(sorted[1].id().unwrap(), "r-a");
1002    }
1003
1004    #[test]
1005    fn romanized_author_sort_uses_hidden_sort_as() {
1006        let locale = make_locale();
1007        let config = romanized_config();
1008        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1009        let reference = multilingual_author_reference(
1010            "tolstoy",
1011            "Толстой".into(),
1012            Some("Tolstoy"),
1013            Some("Tolstoĭ"),
1014            Title::Single("War and Peace".to_string()),
1015        );
1016
1017        assert_eq!(
1018            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1019            "Tolstoy"
1020        );
1021    }
1022
1023    #[test]
1024    fn uniform_author_sort_ignores_hidden_sort_as() {
1025        let locale = make_locale();
1026        let sorter = ReferenceSorter::new(&locale);
1027        let reference = multilingual_author_reference(
1028            "tolstoy",
1029            "Толстой".into(),
1030            Some("Tolstoy"),
1031            Some("Tolstoĭ"),
1032            Title::Single("War and Peace".to_string()),
1033        );
1034
1035        assert_eq!(
1036            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1037            "Толстой"
1038        );
1039    }
1040
1041    #[test]
1042    fn romanized_author_sort_falls_back_to_matched_transliteration() {
1043        let locale = make_locale();
1044        let config = romanized_config();
1045        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1046        let reference = multilingual_author_reference(
1047            "tolstoy",
1048            "Толстой".into(),
1049            None,
1050            Some("Tolstoĭ"),
1051            Title::Single("War and Peace".to_string()),
1052        );
1053
1054        assert_eq!(
1055            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1056            "Tolstoĭ"
1057        );
1058    }
1059
1060    #[test]
1061    fn holistic_sort_as_wins_over_part_level_sort_as() {
1062        let locale = make_locale();
1063        let config = romanized_config();
1064        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1065        let family = MultilingualString::Complex(MultilingualComplex {
1066            original: "Толстой".to_string(),
1067            lang: Some("ru".into()),
1068            sort_as: Some("Part Level".to_string()),
1069            transliterations: HashMap::new(),
1070            translations: HashMap::new(),
1071        });
1072        let reference = multilingual_author_reference(
1073            "tolstoy",
1074            family,
1075            Some("Whole Name"),
1076            Some("Tolstoĭ"),
1077            Title::Single("War and Peace".to_string()),
1078        );
1079
1080        assert_eq!(
1081            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1082            "Whole Name"
1083        );
1084    }
1085
1086    #[test]
1087    fn title_sort_uses_hidden_sort_as_under_romanized_mode() {
1088        let locale = make_locale();
1089        let config = romanized_config();
1090        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1091        let cyrillic = multilingual_author_reference(
1092            "cyrillic-title",
1093            "Smith".into(),
1094            None,
1095            None,
1096            Title::Multilingual(MultilingualComplex {
1097                original: "Война и мир".to_string(),
1098                lang: Some("ru".into()),
1099                sort_as: Some("Academic War and Peace".to_string()),
1100                transliterations: HashMap::new(),
1101                translations: HashMap::new(),
1102            }),
1103        );
1104        let latin = make_reference_no_author("latin-title", "book", "Beta Studies", 2000);
1105
1106        assert_eq!(
1107            title_sort(&sorter, vec![&latin, &cyrillic]),
1108            vec!["cyrillic-title".to_string(), "latin-title".to_string()]
1109        );
1110    }
1111}