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, collator_locale_id, flat_names_sort_key, normalize_sort_text,
29    title_sort_key_with_options,
30};
31
32enum PrimaryContributorSpec<'a> {
33    Citation(&'a citum_schema::CitationSpec),
34    Bibliography(&'a citum_schema::BibliographySpec),
35}
36
37/// Compares two optional values, with `None` sorting after every `Some`.
38/// `Option::cmp`'s default (`None` first) is not equivalent and must not be
39/// substituted for this. Shared by full-date `Issued` comparisons
40/// (`ReferenceSorter::compare_by_issued`, `cache_sort_value`) and reference-id
41/// comparisons (`ReferenceSorter::compare_cached_ids`,
42/// `Disambiguator::compare_reference_ids` in `processor/disambiguation.rs`)
43/// so the tiebreak semantics can't drift between the two — csl26-m8la,
44/// PR #1150 review.
45pub(crate) fn compare_none_last<T: Ord>(a: Option<T>, b: Option<T>) -> std::cmp::Ordering {
46    match (a, b) {
47        (Some(a), Some(b)) => a.cmp(&b),
48        (Some(_), None) => std::cmp::Ordering::Less,
49        (None, Some(_)) => std::cmp::Ordering::Greater,
50        (None, None) => std::cmp::Ordering::Equal,
51    }
52}
53
54/// Sorts grouped bibliography entries using group-specific sort rules.
55pub struct ReferenceSorter<'a> {
56    locale: &'a Locale,
57    text_collator: TextCollator,
58    sort_key_options: SortKeyOptions,
59    config: Option<&'a Config>,
60    primary_contributor_spec: Option<PrimaryContributorSpec<'a>>,
61    primary_contributor_may_be_list: bool,
62}
63
64struct CachedReference<'a> {
65    reference: &'a Reference,
66    sort_values: Vec<CachedSortValue>,
67    /// Reference ID, cached once per reference so the ID tiebreak does not
68    /// re-derive it on every pairwise comparison. Populated only when sorting
69    /// via [`ReferenceSorter::sort_references_with_id_tiebreak`]; `None` otherwise
70    /// to keep ID-less sorts allocation-free.
71    id: Option<String>,
72}
73
74enum CachedSortValue {
75    RefType { name: String, rank: Option<usize> },
76    OptionalText(Option<String>),
77    Text(String),
78    Issued(Option<(i32, u32, u32)>),
79}
80
81enum CompiledSortKey<'a> {
82    RefType {
83        ascending: bool,
84        rank_by_type: Option<HashMap<String, usize>>,
85    },
86    Author {
87        ascending: bool,
88        name_order: NameSortOrder,
89    },
90    Title {
91        ascending: bool,
92    },
93    Issued {
94        ascending: bool,
95    },
96    Field {
97        ascending: bool,
98        field_name: &'a str,
99    },
100}
101
102impl<'a> ReferenceSorter<'a> {
103    /// Create a sorter that uses `locale` for locale-sensitive comparisons.
104    #[must_use]
105    pub fn new(locale: &'a Locale) -> Self {
106        Self {
107            locale,
108            text_collator: TextCollator::new(locale),
109            sort_key_options: SortKeyOptions::uniform(),
110            config: None,
111            primary_contributor_spec: None,
112            primary_contributor_may_be_list: false,
113        }
114    }
115
116    /// Create a bibliography sorter using the effective bibliography config.
117    #[must_use]
118    pub fn with_bibliography_config(locale: &'a Locale, config: &'a Config) -> Self {
119        Self {
120            locale,
121            text_collator: TextCollator::new_for_locale_id(collator_locale_id(locale, config)),
122            sort_key_options: SortKeyOptions::from_config(config),
123            config: Some(config),
124            primary_contributor_spec: None,
125            primary_contributor_may_be_list: false,
126        }
127    }
128
129    /// Use the effective bibliography template to resolve list-form author keys.
130    #[must_use]
131    pub fn with_bibliography_spec(mut self, spec: &'a citum_schema::BibliographySpec) -> Self {
132        self.primary_contributor_spec = Some(PrimaryContributorSpec::Bibliography(spec));
133        self.primary_contributor_may_be_list = bibliography_may_have_list_primary(spec);
134        self
135    }
136
137    /// Use the effective citation template to resolve list-form author keys.
138    #[must_use]
139    pub fn with_citation_spec(mut self, spec: &'a citum_schema::CitationSpec) -> Self {
140        self.primary_contributor_spec = Some(PrimaryContributorSpec::Citation(spec));
141        self.primary_contributor_may_be_list = citation_may_have_list_primary(spec);
142        self
143    }
144
145    /// Sort references according to a group sort specification.
146    ///
147    /// Applies sort keys in order, with later keys acting as tiebreakers.
148    ///
149    /// # Arguments
150    ///
151    /// * `references` - References to sort
152    /// * `sort_spec` - Group sort specification
153    #[must_use]
154    pub fn sort_references<'b>(
155        &self,
156        references: Vec<&'b Reference>,
157        sort_spec: &GroupSort,
158    ) -> Vec<&'b Reference> {
159        self.sort_references_impl(references, sort_spec, false)
160    }
161
162    /// Sort references according to a group sort specification, breaking ties
163    /// between references whose keys compare equal by comparing reference IDs.
164    ///
165    /// References without an ID sort after references with one. The tiebreak
166    /// makes config-driven bibliography sorts fully deterministic, and is
167    /// opt-in because most `ReferenceSorter` call sites (grouping/render paths)
168    /// rely on stable-sort/registry order for equal keys instead.
169    ///
170    /// An empty sort template is a no-op: with no keys to compare, references
171    /// keep registry order rather than being reordered by ID alone.
172    #[must_use]
173    pub fn sort_references_with_id_tiebreak<'b>(
174        &self,
175        references: Vec<&'b Reference>,
176        sort_spec: &GroupSort,
177    ) -> Vec<&'b Reference> {
178        self.sort_references_impl(references, sort_spec, true)
179    }
180
181    fn sort_references_impl<'b>(
182        &self,
183        mut references: Vec<&'b Reference>,
184        sort_spec: &GroupSort,
185        id_tiebreak: bool,
186    ) -> Vec<&'b Reference> {
187        let compiled_keys = self.compile_sort_keys(&sort_spec.template);
188        if compiled_keys.is_empty() {
189            return references;
190        }
191
192        let mut cached_references = references
193            .drain(..)
194            .map(|reference| CachedReference {
195                reference,
196                sort_values: compiled_keys
197                    .iter()
198                    .map(|sort_key| self.cache_sort_value(reference, sort_key))
199                    .collect(),
200                id: id_tiebreak.then(|| reference.id().map(|id| id.0)).flatten(),
201            })
202            .collect::<Vec<_>>();
203
204        cached_references.sort_by(|a, b| {
205            let cmp = self.compare_cached_references(a, b, &compiled_keys);
206            if cmp != std::cmp::Ordering::Equal || !id_tiebreak {
207                return cmp;
208            }
209            Self::compare_cached_ids(a, b)
210        });
211        cached_references
212            .into_iter()
213            .map(|entry| entry.reference)
214            .collect()
215    }
216
217    /// Deterministic tiebreaker: compare cached reference IDs as `&str`,
218    /// with a missing ID sorting last (see [`compare_none_last`]).
219    fn compare_cached_ids(a: &CachedReference<'_>, b: &CachedReference<'_>) -> std::cmp::Ordering {
220        compare_none_last(a.id.as_deref(), b.id.as_deref())
221    }
222
223    /// Compare two references by a single sort key.
224    #[must_use]
225    pub fn compare_by_key(
226        &self,
227        a: &Reference,
228        b: &Reference,
229        sort_key: &GroupSortKey,
230    ) -> std::cmp::Ordering {
231        self.compare_by_key_with_context(a, b, sort_key)
232    }
233
234    /// Stably sort arbitrary items by a `GroupSort` template, precomputing
235    /// each item's sort key set once instead of re-deriving it (author,
236    /// title, issued resolution) on every pairwise comparison.
237    ///
238    /// This generalizes the Schwartzian-transform caching
239    /// [`Self::sort_references_impl`] applies to `&Reference` slices to any
240    /// item type, via a reference-extraction closure — letting comparator
241    /// call sites that don't sort bare `&Reference` (year-suffix ordering,
242    /// citation-item ordering) share the same cached comparison instead of
243    /// calling [`Self::compare_by_key`] from scratch on every pair.
244    ///
245    /// Items whose extractor returns `None` sort after every item with a
246    /// resolved reference; ties (including `None` vs `None`) preserve their
247    /// relative order (`sort_by` is stable).
248    pub(crate) fn sort_by_keys<T>(
249        &self,
250        mut items: Vec<T>,
251        template: &[GroupSortKey],
252        reference_of: impl Fn(&T) -> Option<&Reference>,
253    ) -> Vec<T> {
254        let compiled_keys = self.compile_sort_keys(template);
255        let mut decorated = items
256            .drain(..)
257            .map(|item| {
258                let sort_values = reference_of(&item).map(|reference| {
259                    compiled_keys
260                        .iter()
261                        .map(|sort_key| self.cache_sort_value(reference, sort_key))
262                        .collect::<Vec<_>>()
263                });
264                (item, sort_values)
265            })
266            .collect::<Vec<_>>();
267
268        decorated.sort_by(|(_, a), (_, b)| match (a, b) {
269            (Some(a), Some(b)) => self.compare_cached_values(a, b, &compiled_keys),
270            (Some(_), None) => std::cmp::Ordering::Less,
271            (None, Some(_)) => std::cmp::Ordering::Greater,
272            (None, None) => std::cmp::Ordering::Equal,
273        });
274
275        decorated.into_iter().map(|(item, _)| item).collect()
276    }
277
278    fn compare_by_key_with_context(
279        &self,
280        a: &Reference,
281        b: &Reference,
282        sort_key: &GroupSortKey,
283    ) -> std::cmp::Ordering {
284        let cmp = match &sort_key.key {
285            GroupSortKeyType::RefType => sort_key.order.as_ref().map_or_else(
286                || a.ref_type().cmp(&b.ref_type()),
287                |order| Self::compare_by_type_order(a, b, order),
288            ),
289            GroupSortKeyType::Author => sort_key.sort_order.as_ref().map_or_else(
290                || self.compare_by_author_with_order(a, b, NameSortOrder::FamilyGiven),
291                |name_order| self.compare_by_author_with_order(a, b, *name_order),
292            ),
293            GroupSortKeyType::Title => self.compare_by_title(a, b),
294            GroupSortKeyType::Issued => Self::compare_by_issued(a, b),
295            GroupSortKeyType::Field(field_name) => Self::compare_by_field(a, b, field_name),
296        };
297
298        if sort_key.ascending {
299            cmp
300        } else {
301            cmp.reverse()
302        }
303    }
304
305    /// Compare by type using explicit order sequence.
306    ///
307    /// Types appear in the order specified, regardless of alphabetical content.
308    /// Types not in the order list sort after those in the list, alphabetically.
309    fn compare_by_type_order(a: &Reference, b: &Reference, order: &[String]) -> std::cmp::Ordering {
310        let a_type = a.ref_type();
311        let b_type = b.ref_type();
312
313        let a_pos = order.iter().position(|t| t == &a_type);
314        let b_pos = order.iter().position(|t| t == &b_type);
315
316        match (a_pos, b_pos) {
317            (Some(a_idx), Some(b_idx)) => a_idx.cmp(&b_idx),
318            (Some(_), None) => std::cmp::Ordering::Less, // a in order, b not
319            (None, Some(_)) => std::cmp::Ordering::Greater, // b in order, a not
320            (None, None) => a_type.cmp(&b_type),         // both not in order, alphabetical
321        }
322    }
323
324    fn compile_sort_keys<'b>(&self, template: &'b [GroupSortKey]) -> Vec<CompiledSortKey<'b>> {
325        template
326            .iter()
327            .map(|sort_key| match &sort_key.key {
328                GroupSortKeyType::RefType => CompiledSortKey::RefType {
329                    ascending: sort_key.ascending,
330                    rank_by_type: sort_key.order.as_ref().map(|order| {
331                        order
332                            .iter()
333                            .enumerate()
334                            .map(|(index, ref_type)| (ref_type.clone(), index))
335                            .collect()
336                    }),
337                },
338                GroupSortKeyType::Author => CompiledSortKey::Author {
339                    ascending: sort_key.ascending,
340                    name_order: sort_key.sort_order.unwrap_or(NameSortOrder::FamilyGiven),
341                },
342                GroupSortKeyType::Title => CompiledSortKey::Title {
343                    ascending: sort_key.ascending,
344                },
345                GroupSortKeyType::Issued => CompiledSortKey::Issued {
346                    ascending: sort_key.ascending,
347                },
348                GroupSortKeyType::Field(field_name) => CompiledSortKey::Field {
349                    ascending: sort_key.ascending,
350                    field_name,
351                },
352            })
353            .collect()
354    }
355
356    fn cache_sort_value(
357        &self,
358        reference: &Reference,
359        sort_key: &CompiledSortKey<'_>,
360    ) -> CachedSortValue {
361        match sort_key {
362            CompiledSortKey::RefType { rank_by_type, .. } => {
363                let ref_type = reference.ref_type();
364                CachedSortValue::RefType {
365                    name: ref_type.clone(),
366                    rank: rank_by_type
367                        .as_ref()
368                        .and_then(|ranks| ranks.get(&ref_type).copied()),
369                }
370            }
371            CompiledSortKey::Author { name_order, .. } => CachedSortValue::OptionalText(
372                self.extract_author_sort_key_opt(reference, *name_order),
373            ),
374            CompiledSortKey::Title { .. } => CachedSortValue::Text(self.title_sort_key(reference)),
375            CompiledSortKey::Issued { .. } => {
376                CachedSortValue::Issued(Self::issued_date_parts(reference))
377            }
378            CompiledSortKey::Field { field_name, .. } => {
379                CachedSortValue::Text(Self::field_sort_value(reference, field_name))
380            }
381        }
382    }
383
384    fn compare_cached_references(
385        &self,
386        a: &CachedReference<'_>,
387        b: &CachedReference<'_>,
388        compiled_keys: &[CompiledSortKey<'_>],
389    ) -> std::cmp::Ordering {
390        self.compare_cached_values(&a.sort_values, &b.sort_values, compiled_keys)
391    }
392
393    /// Compare two precomputed sort-value sequences key by key, honoring
394    /// each key's ascending/descending direction and stopping at the first
395    /// non-equal comparison. Shared by [`Self::compare_cached_references`]
396    /// (bibliography sorts) and [`Self::sort_by_keys`] (generic item sorts).
397    fn compare_cached_values(
398        &self,
399        a: &[CachedSortValue],
400        b: &[CachedSortValue],
401        compiled_keys: &[CompiledSortKey<'_>],
402    ) -> std::cmp::Ordering {
403        for (index, sort_key) in compiled_keys.iter().enumerate() {
404            #[allow(
405                clippy::indexing_slicing,
406                reason = "index is derived from compiled_keys"
407            )]
408            let cmp = self.compare_cached_value(&a[index], &b[index]);
409            let cmp = if Self::is_ascending(sort_key) {
410                cmp
411            } else {
412                cmp.reverse()
413            };
414
415            if cmp != std::cmp::Ordering::Equal {
416                return cmp;
417            }
418        }
419
420        std::cmp::Ordering::Equal
421    }
422
423    fn compare_cached_value(&self, a: &CachedSortValue, b: &CachedSortValue) -> std::cmp::Ordering {
424        match (a, b) {
425            (
426                CachedSortValue::RefType {
427                    name: a_name,
428                    rank: a_rank,
429                },
430                CachedSortValue::RefType {
431                    name: b_name,
432                    rank: b_rank,
433                },
434            ) => match (a_rank, b_rank) {
435                (Some(a_idx), Some(b_idx)) => a_idx.cmp(b_idx),
436                (Some(_), None) => std::cmp::Ordering::Less,
437                (None, Some(_)) => std::cmp::Ordering::Greater,
438                (None, None) => a_name.cmp(b_name),
439            },
440            (CachedSortValue::OptionalText(a_text), CachedSortValue::OptionalText(b_text)) => {
441                match (a_text, b_text) {
442                    (Some(a_value), Some(b_value)) => self.text_collator.compare(a_value, b_value),
443                    (Some(_), None) => std::cmp::Ordering::Less,
444                    (None, Some(_)) => std::cmp::Ordering::Greater,
445                    (None, None) => std::cmp::Ordering::Equal,
446                }
447            }
448            (CachedSortValue::Text(a_text), CachedSortValue::Text(b_text)) => {
449                self.text_collator.compare(a_text, b_text)
450            }
451            (CachedSortValue::Issued(a_date), CachedSortValue::Issued(b_date)) => {
452                compare_none_last(*a_date, *b_date)
453            }
454            _ => std::cmp::Ordering::Equal,
455        }
456    }
457
458    fn is_ascending(sort_key: &CompiledSortKey<'_>) -> bool {
459        match sort_key {
460            CompiledSortKey::RefType { ascending, .. }
461            | CompiledSortKey::Author { ascending, .. }
462            | CompiledSortKey::Title { ascending }
463            | CompiledSortKey::Issued { ascending }
464            | CompiledSortKey::Field { ascending, .. } => *ascending,
465        }
466    }
467
468    /// Compare by author with culturally appropriate name ordering.
469    fn compare_by_author_with_order(
470        &self,
471        a: &Reference,
472        b: &Reference,
473        name_order: NameSortOrder,
474    ) -> std::cmp::Ordering {
475        let a_key = self.extract_author_sort_key_opt(a, name_order);
476        let b_key = self.extract_author_sort_key_opt(b, name_order);
477        match (a_key, b_key) {
478            (Some(a), Some(b)) => self.text_collator.compare(&a, &b),
479            (Some(_), None) => std::cmp::Ordering::Less,
480            (None, Some(_)) => std::cmp::Ordering::Greater,
481            (None, None) => std::cmp::Ordering::Equal,
482        }
483    }
484
485    /// Extract author sort key with specified name ordering.
486    ///
487    /// Unlike generic bibliography sorting, author-key sorting follows CSL
488    /// semantics for name keys: items without author/editor names are treated
489    /// as missing-name entries and sort after named entries.
490    fn extract_author_sort_key_opt(
491        &self,
492        reference: &Reference,
493        name_order: NameSortOrder,
494    ) -> Option<String> {
495        let default_config = Config::default();
496        let config = self.config.unwrap_or(&default_config);
497
498        if let Some(component) = self.primary_contributor_component(reference)
499            && component.contributor.is_multiple()
500        {
501            let names = crate::values::contributor::merged::semantic_names(
502                &component,
503                reference,
504                config,
505                self.locale,
506            );
507            if let Some(key) = flat_names_sort_key(&names, name_order) {
508                return Some(key);
509            }
510            // An empty merged template component (e.g. a type variant's
511            // `[writer, director]` primary with neither present) falls
512            // through to the effective-primary resolver below instead of
513            // jumping straight to the title key, so sorting can still walk
514            // the substitute chain the render path uses
515            // (`merged.rs::resolve_empty_list`) and land on, say, an editor.
516        }
517        let substitute =
518            citum_schema::options::SubstituteConfig::resolve_or_default(config.substitute.as_ref());
519        let primary_key = match crate::values::contributor::substitute::effective_primary(
520            reference,
521            substitute.as_ref(),
522            config,
523            self.locale,
524        ) {
525            Some(crate::values::contributor::substitute::EffectivePrimary::Contributor {
526                contributor,
527                ..
528            }) => crate::sort_support::contributor_sort_key(
529                &contributor,
530                name_order,
531                &self.sort_key_options,
532            ),
533            Some(crate::values::contributor::substitute::EffectivePrimary::Merged(roles)) => {
534                let names = crate::values::contributor::merged::semantic_names(
535                    &citum_schema::template::TemplateContributor {
536                        contributor: roles,
537                        ..Default::default()
538                    },
539                    reference,
540                    config,
541                    self.locale,
542                );
543                flat_names_sort_key(&names, name_order)
544            }
545            Some(crate::values::contributor::substitute::EffectivePrimary::Title { .. }) | None => {
546                None
547            }
548        };
549        primary_key.or_else(|| {
550            Some(title_sort_key_with_options(
551                reference,
552                self.locale,
553                &self.sort_key_options,
554            ))
555            .filter(|key| !key.is_empty())
556        })
557    }
558
559    fn primary_contributor_component(
560        &self,
561        reference: &Reference,
562    ) -> Option<citum_schema::template::TemplateContributor> {
563        if !self.primary_contributor_may_be_list {
564            return None;
565        }
566        let language = reference.language().map(|language| language.to_string());
567        match self.primary_contributor_spec.as_ref()? {
568            PrimaryContributorSpec::Citation(spec) => {
569                primary_contributor_for_citation(spec, reference)
570            }
571            PrimaryContributorSpec::Bibliography(spec) => {
572                primary_contributor_for_bibliography(spec, reference, language.as_deref())
573            }
574        }
575    }
576
577    /// Public helper retained for tests/debugging.
578    #[must_use]
579    pub fn extract_author_sort_key(
580        &self,
581        reference: &Reference,
582        name_order: NameSortOrder,
583    ) -> String {
584        self.extract_author_sort_key_opt(reference, name_order)
585            .unwrap_or_default()
586    }
587
588    /// Compare by title (with article stripping).
589    fn compare_by_title(&self, a: &Reference, b: &Reference) -> std::cmp::Ordering {
590        let a_title = self.title_sort_key(a);
591        let b_title = self.title_sort_key(b);
592        self.text_collator.compare(&a_title, &b_title)
593    }
594
595    /// Compare by full issued date (year, month, day).
596    fn compare_by_issued(a: &Reference, b: &Reference) -> std::cmp::Ordering {
597        compare_none_last(Self::issued_date_parts(a), Self::issued_date_parts(b))
598    }
599
600    /// Compare by custom field.
601    fn compare_by_field(a: &Reference, b: &Reference, field_name: &str) -> std::cmp::Ordering {
602        Self::field_sort_value(a, field_name).cmp(&Self::field_sort_value(b, field_name))
603    }
604
605    fn title_sort_key(&self, reference: &Reference) -> String {
606        title_sort_key_with_options(reference, self.locale, &self.sort_key_options)
607    }
608
609    /// Full (year, month, day) for sort comparison. A missing month or day
610    /// defaults to `0`, sorting before any real value - the less precise a
611    /// date is, the earlier it's treated, matching EDTF's own
612    /// could-be-anywhere-in-range convention for reduced precision.
613    fn issued_date_parts(reference: &Reference) -> Option<(i32, u32, u32)> {
614        let date = reference.effective_issued_date()?;
615        let (year, month, day) = date.date_parts()?;
616        let year = i32::try_from(year).ok().filter(|year| *year != 0)?;
617        Some((year, month.unwrap_or(0), day.unwrap_or(0)))
618    }
619
620    fn field_sort_value(reference: &Reference, field_name: &str) -> String {
621        match field_name {
622            "language" => normalize_sort_text(reference.language().unwrap_or_default().as_ref()),
623            // Future: support for keywords, custom metadata
624            _ => String::new(),
625        }
626    }
627}
628
629fn first_contributor_component_ref(
630    template: &[citum_schema::template::TemplateComponent],
631) -> Option<&citum_schema::template::TemplateContributor> {
632    for component in template {
633        match component {
634            citum_schema::template::TemplateComponent::Contributor(contributor) => {
635                return Some(contributor);
636            }
637            citum_schema::template::TemplateComponent::Group(group) => {
638                if let Some(contributor) = first_contributor_component_ref(&group.group) {
639                    return Some(contributor);
640                }
641            }
642            _ => {}
643        }
644    }
645    None
646}
647
648fn first_contributor_component(
649    template: &[citum_schema::template::TemplateComponent],
650) -> Option<citum_schema::template::TemplateContributor> {
651    first_contributor_component_ref(template).cloned()
652}
653
654fn template_has_list_primary(template: &[citum_schema::template::TemplateComponent]) -> bool {
655    first_contributor_component_ref(template)
656        .is_some_and(|component| component.contributor.is_multiple())
657}
658
659fn variants_may_have_list_primary(variants: &citum_schema::template::TemplateVariants) -> bool {
660    variants
661        .values()
662        .any(|variant| variant.as_template().is_none_or(template_has_list_primary))
663}
664
665/// Return whether any template reachable from this citation spec (base,
666/// locale overrides, type variants, or integral/non-integral/subsequent/ibid
667/// forms) declares a merged-list primary contributor component.
668pub(crate) fn citation_may_have_list_primary(spec: &citum_schema::CitationSpec) -> bool {
669    spec.template
670        .as_ref()
671        .and_then(citum_schema::template::TemplateVariant::as_template)
672        .is_some_and(template_has_list_primary)
673        || spec
674            .template_ref
675            .as_ref()
676            .and_then(citum_schema::template::TemplateReference::citation_template)
677            .as_deref()
678            .is_some_and(template_has_list_primary)
679        || spec.locales.as_ref().is_some_and(|locales| {
680            locales
681                .iter()
682                .any(|localized| template_has_list_primary(&localized.template))
683        })
684        || spec
685            .type_variants
686            .as_ref()
687            .is_some_and(variants_may_have_list_primary)
688        || spec
689            .integral
690            .as_deref()
691            .is_some_and(citation_may_have_list_primary)
692        || spec
693            .non_integral
694            .as_deref()
695            .is_some_and(citation_may_have_list_primary)
696        || spec
697            .subsequent
698            .as_deref()
699            .is_some_and(citation_may_have_list_primary)
700        || spec
701            .ibid
702            .as_deref()
703            .is_some_and(citation_may_have_list_primary)
704}
705
706fn bibliography_may_have_list_primary(spec: &citum_schema::BibliographySpec) -> bool {
707    spec.template
708        .as_ref()
709        .and_then(citum_schema::template::TemplateVariant::as_template)
710        .is_some_and(template_has_list_primary)
711        || spec
712            .template_ref
713            .as_ref()
714            .and_then(citum_schema::template::TemplateReference::bibliography_template)
715            .as_deref()
716            .is_some_and(template_has_list_primary)
717        || spec.locales.as_ref().is_some_and(|locales| {
718            locales
719                .iter()
720                .any(|localized| template_has_list_primary(&localized.template))
721        })
722        || spec
723            .type_variants
724            .as_ref()
725            .is_some_and(variants_may_have_list_primary)
726}
727
728/// Resolve the first contributor component from a reference's effective citation template.
729pub(crate) fn primary_contributor_for_citation(
730    spec: &citum_schema::CitationSpec,
731    reference: &Reference,
732) -> Option<citum_schema::template::TemplateContributor> {
733    let language = reference.language().map(|language| language.to_string());
734    let template = spec.resolve_template_for_type(&reference.ref_type(), language.as_deref())?;
735    first_contributor_component(&template)
736}
737
738fn primary_contributor_for_bibliography(
739    spec: &citum_schema::BibliographySpec,
740    reference: &Reference,
741    language: Option<&str>,
742) -> Option<citum_schema::template::TemplateContributor> {
743    let template = spec.resolve_template_for_type(&reference.ref_type(), language)?;
744    first_contributor_component(&template)
745}
746
747fn first_date_component_ref(
748    template: &[citum_schema::template::TemplateComponent],
749) -> Option<&citum_schema::template::TemplateDate> {
750    for component in template {
751        match component {
752            citum_schema::template::TemplateComponent::Date(date)
753                if date.suppress_disamb_suffix != Some(true) =>
754            {
755                return Some(date);
756            }
757            citum_schema::template::TemplateComponent::Group(group) => {
758                if let Some(date) = first_date_component_ref(&group.group) {
759                    return Some(date);
760                }
761            }
762            _ => {}
763        }
764    }
765    None
766}
767
768fn first_date_component(
769    template: &[citum_schema::template::TemplateComponent],
770) -> Option<citum_schema::template::TemplateDate> {
771    first_date_component_ref(template).cloned()
772}
773
774/// Resolve the first non-suppressed date component from a reference's
775/// effective citation template — the identity-bearing date slot under the
776/// author, not a later display-only date marked `suppress-disamb-suffix`.
777/// Structurally mirrors `primary_contributor_for_citation`'s resolution
778/// path, but `Disambiguator` consults this only as a *fallback* to
779/// `first_date_component_for_bibliography`, the reverse of the author-slot
780/// preference order — see that function's doc comment. See `csl26-huuz`.
781pub(crate) fn first_date_component_for_citation(
782    spec: &citum_schema::CitationSpec,
783    reference: &Reference,
784) -> Option<citum_schema::template::TemplateDate> {
785    let language = crate::values::effective_item_language(reference);
786    let template = spec.resolve_template_for_type(&reference.ref_type(), language.as_deref())?;
787    first_date_component(&template)
788}
789
790/// Bibliography-spec counterpart of `first_date_component_for_citation`.
791///
792/// `Disambiguator::date_slot_discriminant` tries this **first**, falling
793/// back to `first_date_component_for_citation` only when no bibliography
794/// spec is configured or it resolves no date component — the opposite
795/// preference order from the author-slot list-primary resolution in
796/// `build_reference_cache`. A style's `citation:` template is commonly a
797/// simpler, non-type-differentiated form of the same date logic (e.g.
798/// `gb-t-7714-2025-author-date`'s `citation:` section has one flat
799/// `date: issued` with no `type-variants:` at all, unlike its
800/// `bibliography:` section); preferring it here would let that
801/// undifferentiated template collapse every undated reference onto the same
802/// discriminant regardless of type, defeating the type-conditional split
803/// this mechanism exists to make. Confirmed empirically against the GB/T
804/// oracle before landing this order — see `csl26-huuz` and
805/// `docs/specs/DISAMBIGUATION.md` §1.
806///
807/// Both this function and `first_date_component_for_citation` resolve their
808/// language via `effective_item_language`, matching the real render path
809/// (`processor/rendering/mod.rs::locale_for_reference`) and
810/// `Disambiguator::date_component_discriminant`'s own message-term
811/// scoping — not the bare `reference.language()` the pre-existing
812/// `primary_contributor_for_citation`/`primary_contributor_for_bibliography`
813/// use, a latent, unrelated inconsistency this function does not fix.
814/// Flagged in PR review for csl26-huuz.
815pub(crate) fn first_date_component_for_bibliography(
816    spec: &citum_schema::BibliographySpec,
817    reference: &Reference,
818) -> Option<citum_schema::template::TemplateDate> {
819    let language = crate::values::effective_item_language(reference);
820    let template = spec.resolve_template_for_type(&reference.ref_type(), language.as_deref())?;
821    first_date_component(&template)
822}
823
824#[cfg(test)]
825#[allow(
826    clippy::unwrap_used,
827    clippy::expect_used,
828    clippy::panic,
829    clippy::indexing_slicing,
830    clippy::todo,
831    clippy::unimplemented,
832    clippy::unreachable,
833    clippy::get_unwrap,
834    reason = "Panicking is acceptable and often desired in tests."
835)]
836mod tests {
837    use super::*;
838    use citum_schema::grouping::GroupSortKey;
839    use citum_schema::options::{MultilingualConfig, SortingConfig, SortingMultilingualMode};
840    use citum_schema::reference::contributor::MultilingualName;
841    use citum_schema::reference::types::MultilingualComplex;
842    use citum_schema::reference::{
843        Contributor, ContributorList, DateValue, Monograph, MonographType, MultilingualString,
844        StructuredName, Title,
845    };
846    use std::collections::HashMap;
847
848    fn make_locale() -> Locale {
849        Locale::en_us()
850    }
851
852    fn make_reference(
853        id: &str,
854        ref_type: &str,
855        author_family: &str,
856        title: &str,
857        year: i32,
858    ) -> Reference {
859        let json = serde_json::json!({
860            "id": id,
861            "type": ref_type,
862            "author": [{"family": author_family, "given": "Test"}],
863            "issued": {"date-parts": [[year]]},
864            "title": title,
865            "container-title": "Test Container",
866        });
867        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
868        legacy.into()
869    }
870
871    fn make_reference_no_author(id: &str, ref_type: &str, title: &str, year: i32) -> Reference {
872        let json = serde_json::json!({
873            "id": id,
874            "type": ref_type,
875            "issued": {"date-parts": [[year]]},
876            "title": title,
877            "container-title": "Test Container",
878        });
879        let legacy: csl_legacy::csl_json::Reference = serde_json::from_value(json).unwrap();
880        legacy.into()
881    }
882
883    fn romanized_config() -> Config {
884        Config {
885            sorting: Some(SortingConfig {
886                multilingual: Some(SortingMultilingualMode::Romanized),
887                ..Default::default()
888            }),
889            multilingual: Some(MultilingualConfig {
890                preferred_transliteration: Some(vec!["ru-Latn-alalc97".to_string()]),
891                ..Default::default()
892            }),
893            ..Default::default()
894        }
895    }
896
897    fn multilingual_author_reference(
898        id: &str,
899        original_family: MultilingualString,
900        sort_as: Option<&str>,
901        transliteration_family: Option<&str>,
902        title: Title,
903    ) -> Reference {
904        let transliterations = transliteration_family.map_or_else(HashMap::new, |family| {
905            HashMap::from([(
906                "ru-Latn-alalc97".to_string(),
907                StructuredName {
908                    family: family.into(),
909                    given: "Lev".into(),
910                    ..Default::default()
911                },
912            )])
913        });
914
915        Reference::Monograph(Box::new(Monograph {
916            id: Some(id.into()),
917            r#type: MonographType::Book,
918            title: Some(title),
919            author: Some(Contributor::ContributorList(ContributorList(vec![
920                Contributor::Multilingual(MultilingualName {
921                    original: StructuredName {
922                        family: original_family,
923                        given: "Лев".into(),
924                        ..Default::default()
925                    },
926                    lang: Some("ru".into()),
927                    sort_as: sort_as.map(str::to_string),
928                    transliterations,
929                    translations: HashMap::new(),
930                }),
931            ]))),
932            issued: DateValue::new("1869".to_string()),
933            ..Default::default()
934        }))
935    }
936
937    fn title_sort(sorter: &ReferenceSorter<'_>, references: Vec<&Reference>) -> Vec<String> {
938        let sort_spec = GroupSort {
939            template: vec![GroupSortKey {
940                key: GroupSortKeyType::Title,
941                ascending: true,
942                order: None,
943                sort_order: None,
944            }],
945        };
946
947        sorter
948            .sort_references(references, &sort_spec)
949            .into_iter()
950            .map(|reference| reference.id().expect("test reference id").to_string())
951            .collect()
952    }
953
954    #[test]
955    fn test_type_order_sorting() {
956        let locale = make_locale();
957        let sorter = ReferenceSorter::new(&locale);
958
959        // Use standard CSL JSON types for testing
960        let journal = make_reference("r1", "article-journal", "Smith", "Title J", 1990);
961        let magazine = make_reference("r2", "article-magazine", "Jones", "Title M", 2000);
962        let newspaper = make_reference("r3", "article-newspaper", "Brown", "Title N", 1985);
963        let book = make_reference("r4", "book", "Davis", "Title B", 1995);
964
965        let mut refs = vec![&book, &newspaper, &journal, &magazine];
966
967        let sort_spec = GroupSort {
968            template: vec![GroupSortKey {
969                key: GroupSortKeyType::RefType,
970                ascending: true,
971                order: Some(vec![
972                    "article-journal".to_string(),
973                    "article-magazine".to_string(),
974                    "article-newspaper".to_string(),
975                ]),
976                sort_order: None,
977            }],
978        };
979
980        refs = sorter.sort_references(refs, &sort_spec);
981
982        // Should be: article-journal, article-magazine, article-newspaper, then book (alphabetically after)
983        assert_eq!(refs[0].id().unwrap(), "r1"); // article-journal
984        assert_eq!(refs[1].id().unwrap(), "r2"); // article-magazine
985        assert_eq!(refs[2].id().unwrap(), "r3"); // article-newspaper
986        assert_eq!(refs[3].id().unwrap(), "r4"); // book
987    }
988
989    #[test]
990    fn test_author_family_given_order() {
991        let locale = make_locale();
992        let sorter = ReferenceSorter::new(&locale);
993
994        let smith = make_reference("r1", "book", "Smith", "Title", 2000);
995        let jones = make_reference("r2", "book", "Jones", "Title", 2000);
996        let brown = make_reference("r3", "book", "Brown", "Title", 2000);
997
998        let mut refs = vec![&smith, &jones, &brown];
999
1000        let sort_spec = GroupSort {
1001            template: vec![GroupSortKey {
1002                key: GroupSortKeyType::Author,
1003                ascending: true,
1004                order: None,
1005                sort_order: Some(NameSortOrder::FamilyGiven),
1006            }],
1007        };
1008
1009        refs = sorter.sort_references(refs, &sort_spec);
1010
1011        // Should be alphabetical by family name
1012        assert_eq!(refs[0].id().unwrap(), "r3"); // Brown
1013        assert_eq!(refs[1].id().unwrap(), "r2"); // Jones
1014        assert_eq!(refs[2].id().unwrap(), "r1"); // Smith
1015    }
1016
1017    #[test]
1018    #[cfg(feature = "icu")]
1019    fn test_author_sort_uses_unicode_collation_for_accented_names() {
1020        let locale = make_locale();
1021        let sorter = ReferenceSorter::new(&locale);
1022
1023        let celik = make_reference("r1", "book", "Çelik", "Title", 2000);
1024        let zimring = make_reference("r2", "book", "Zimring", "Title", 2000);
1025        let o_tuathail = make_reference("r3", "book", "Ó Tuathail", "Title", 2000);
1026
1027        let mut refs = vec![&o_tuathail, &zimring, &celik];
1028
1029        let sort_spec = GroupSort {
1030            template: vec![GroupSortKey {
1031                key: GroupSortKeyType::Author,
1032                ascending: true,
1033                order: None,
1034                sort_order: Some(NameSortOrder::FamilyGiven),
1035            }],
1036        };
1037
1038        refs = sorter.sort_references(refs, &sort_spec);
1039
1040        assert_eq!(refs[0].id().unwrap(), "r1");
1041        assert_eq!(refs[1].id().unwrap(), "r3");
1042        assert_eq!(refs[2].id().unwrap(), "r2");
1043    }
1044
1045    #[test]
1046    #[cfg(feature = "icu")]
1047    fn test_title_sort_uses_unicode_collation_for_accented_titles() {
1048        let locale = make_locale();
1049        let sorter = ReferenceSorter::new(&locale);
1050
1051        let accent = make_reference_no_author("r1", "book", "Órbitas del sur", 2000);
1052        let plain = make_reference_no_author("r2", "book", "Origins of Theory", 2000);
1053        let zeta = make_reference_no_author("r3", "book", "Zebra Studies", 2000);
1054
1055        let mut refs = vec![&zeta, &plain, &accent];
1056
1057        let sort_spec = GroupSort {
1058            template: vec![GroupSortKey {
1059                key: GroupSortKeyType::Title,
1060                ascending: true,
1061                order: None,
1062                sort_order: None,
1063            }],
1064        };
1065
1066        refs = sorter.sort_references(refs, &sort_spec);
1067
1068        assert_eq!(refs[0].id().unwrap(), "r1");
1069        assert_eq!(refs[1].id().unwrap(), "r2");
1070        assert_eq!(refs[2].id().unwrap(), "r3");
1071    }
1072
1073    #[test]
1074    fn test_issued_descending() {
1075        let locale = make_locale();
1076        let sorter = ReferenceSorter::new(&locale);
1077
1078        let old = make_reference("r1", "book", "Smith", "Title", 1990);
1079        let new = make_reference("r2", "book", "Jones", "Title", 2020);
1080        let mid = make_reference("r3", "book", "Brown", "Title", 2005);
1081
1082        let mut refs = vec![&old, &new, &mid];
1083
1084        let sort_spec = GroupSort {
1085            template: vec![GroupSortKey {
1086                key: GroupSortKeyType::Issued,
1087                ascending: false, // Descending
1088                order: None,
1089                sort_order: None,
1090            }],
1091        };
1092
1093        refs = sorter.sort_references(refs, &sort_spec);
1094
1095        // Should be newest first
1096        assert_eq!(refs[0].id().unwrap(), "r2"); // 2020
1097        assert_eq!(refs[1].id().unwrap(), "r3"); // 2005
1098        assert_eq!(refs[2].id().unwrap(), "r1"); // 1990
1099    }
1100
1101    #[test]
1102    fn test_issued_ascending_places_undated_last() {
1103        let locale = make_locale();
1104        let sorter = ReferenceSorter::new(&locale);
1105
1106        let dated_early = make_reference("r1", "book", "Smith", "Book D", 1999);
1107        let dated_late = make_reference("r2", "book", "Jones", "Book B", 2000);
1108        let mut undated = make_reference("r3", "book", "Brown", "Book A", 2000);
1109        if let ClassExtension::Monograph(monograph) = undated.extension_mut() {
1110            monograph.issued = citum_schema::reference::DateValue::new(String::new());
1111        }
1112
1113        let mut refs = vec![&undated, &dated_late, &dated_early];
1114
1115        let sort_spec = GroupSort {
1116            template: vec![GroupSortKey {
1117                key: GroupSortKeyType::Issued,
1118                ascending: true,
1119                order: None,
1120                sort_order: None,
1121            }],
1122        };
1123
1124        refs = sorter.sort_references(refs, &sort_spec);
1125
1126        assert_eq!(refs[0].id().unwrap(), "r1");
1127        assert_eq!(refs[1].id().unwrap(), "r2");
1128        assert_eq!(refs[2].id().unwrap(), "r3");
1129    }
1130
1131    #[test]
1132    fn test_issued_sort_uses_created_when_issued_is_missing() {
1133        let locale = make_locale();
1134        let sorter = ReferenceSorter::new(&locale);
1135
1136        let dated = make_reference("r1", "book", "Smith", "Book D", 1999);
1137        let mut created_only = make_reference("r2", "book", "Jones", "Book C", 2000);
1138        if let ClassExtension::Monograph(monograph) = created_only.extension_mut() {
1139            monograph.created = citum_schema::reference::DateValue::new("1985".to_string());
1140            monograph.issued = citum_schema::reference::DateValue::new(String::new());
1141        }
1142
1143        let mut refs = vec![&dated, &created_only];
1144
1145        let sort_spec = GroupSort {
1146            template: vec![GroupSortKey {
1147                key: GroupSortKeyType::Issued,
1148                ascending: true,
1149                order: None,
1150                sort_order: None,
1151            }],
1152        };
1153
1154        refs = sorter.sort_references(refs, &sort_spec);
1155
1156        assert_eq!(refs[0].id().unwrap(), "r2");
1157        assert_eq!(refs[1].id().unwrap(), "r1");
1158    }
1159
1160    #[test]
1161    fn test_composite_sort() {
1162        let locale = make_locale();
1163        let sorter = ReferenceSorter::new(&locale);
1164
1165        let smith2020 = make_reference("r1", "book", "Smith", "Title", 2020);
1166        let smith2010 = make_reference("r2", "book", "Smith", "Title", 2010);
1167        let jones2020 = make_reference("r3", "book", "Jones", "Title", 2020);
1168
1169        let mut refs = vec![&smith2020, &jones2020, &smith2010];
1170
1171        let sort_spec = GroupSort {
1172            template: vec![
1173                GroupSortKey {
1174                    key: GroupSortKeyType::Author,
1175                    ascending: true,
1176                    order: None,
1177                    sort_order: Some(NameSortOrder::FamilyGiven),
1178                },
1179                GroupSortKey {
1180                    key: GroupSortKeyType::Issued,
1181                    ascending: false, // Descending within author
1182                    order: None,
1183                    sort_order: None,
1184                },
1185            ],
1186        };
1187
1188        refs = sorter.sort_references(refs, &sort_spec);
1189
1190        // Should be: Jones 2020, then Smith 2020, then Smith 2010
1191        assert_eq!(refs[0].id().unwrap(), "r3"); // Jones 2020
1192        assert_eq!(refs[1].id().unwrap(), "r1"); // Smith 2020
1193        assert_eq!(refs[2].id().unwrap(), "r2"); // Smith 2010
1194    }
1195
1196    #[test]
1197    fn test_author_sort_falls_back_to_title_for_missing_names() {
1198        let locale = make_locale();
1199        let sorter = ReferenceSorter::new(&locale);
1200
1201        let no_author = make_reference_no_author("r1", "legal-case", "Brown v. Board", 1954);
1202        let brown = make_reference("r2", "book", "Brown", "Title", 2000);
1203        let smith = make_reference("r3", "book", "Smith", "Title", 2000);
1204
1205        let mut refs = vec![&no_author, &smith, &brown];
1206
1207        let sort_spec = GroupSort {
1208            template: vec![GroupSortKey {
1209                key: GroupSortKeyType::Author,
1210                ascending: true,
1211                order: None,
1212                sort_order: Some(NameSortOrder::FamilyGiven),
1213            }],
1214        };
1215
1216        refs = sorter.sort_references(refs, &sort_spec);
1217
1218        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown
1219        assert_eq!(refs[1].id().unwrap(), "r1"); // Brown v. Board
1220        assert_eq!(refs[2].id().unwrap(), "r3"); // Smith
1221    }
1222
1223    #[test]
1224    fn test_legal_citation_sort() {
1225        let locale = make_locale();
1226        let sorter = ReferenceSorter::new(&locale);
1227
1228        let case_a = make_reference("r1", "legal-case", "", "Doe v. Smith", 1990);
1229        let case_b = make_reference("r2", "legal-case", "", "Brown v. Board", 1954);
1230
1231        let mut refs = vec![&case_a, &case_b];
1232
1233        let sort_spec = GroupSort {
1234            template: vec![
1235                GroupSortKey {
1236                    key: GroupSortKeyType::Title, // Case name
1237                    ascending: true,
1238                    order: None,
1239                    sort_order: None,
1240                },
1241                GroupSortKey {
1242                    key: GroupSortKeyType::Issued,
1243                    ascending: true,
1244                    order: None,
1245                    sort_order: None,
1246                },
1247            ],
1248        };
1249
1250        refs = sorter.sort_references(refs, &sort_spec);
1251        assert_eq!(refs[0].id().unwrap(), "r2"); // Brown v. Board
1252    }
1253
1254    #[test]
1255    fn test_legal_hierarchy_sort() {
1256        let locale = make_locale();
1257        let sorter = ReferenceSorter::new(&locale);
1258
1259        let statute = make_reference("r1", "statute", "", "Clean Air Act", 1970);
1260        let case = make_reference("r2", "legal-case", "", "Roe v. Wade", 1973);
1261        let treaty = make_reference("r3", "treaty", "", "Paris Agreement", 2015);
1262
1263        let mut refs = vec![&treaty, &case, &statute];
1264
1265        let sort_spec = GroupSort {
1266            template: vec![GroupSortKey {
1267                key: GroupSortKeyType::RefType,
1268                ascending: true,
1269                order: Some(vec![
1270                    "legal-case".to_string(),
1271                    "statute".to_string(),
1272                    "treaty".to_string(),
1273                ]),
1274                sort_order: None,
1275            }],
1276        };
1277
1278        refs = sorter.sort_references(refs, &sort_spec);
1279
1280        // Hierarchy: case, statute, treaty
1281        assert_eq!(refs[0].id().unwrap(), "r2");
1282        assert_eq!(refs[1].id().unwrap(), "r1");
1283        assert_eq!(refs[2].id().unwrap(), "r3");
1284    }
1285
1286    /// Given references whose sort keys are all equal, when sorted with the
1287    /// ID tiebreak, then they come out in ID order.
1288    #[test]
1289    fn test_id_tiebreak_orders_equal_keys_by_id() {
1290        let locale = make_locale();
1291        let sorter = ReferenceSorter::new(&locale);
1292
1293        let c = make_reference("r-c", "book", "Smith", "Same Title", 2000);
1294        let a = make_reference("r-a", "book", "Smith", "Same Title", 2000);
1295        let b = make_reference("r-b", "book", "Smith", "Same Title", 2000);
1296
1297        let refs = vec![&c, &a, &b];
1298
1299        let sort_spec = GroupSort {
1300            template: vec![GroupSortKey {
1301                key: GroupSortKeyType::Author,
1302                ascending: true,
1303                order: None,
1304                sort_order: Some(NameSortOrder::FamilyGiven),
1305            }],
1306        };
1307
1308        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
1309
1310        assert_eq!(sorted[0].id().unwrap(), "r-a");
1311        assert_eq!(sorted[1].id().unwrap(), "r-b");
1312        assert_eq!(sorted[2].id().unwrap(), "r-c");
1313    }
1314
1315    /// Given one reference with no ID and one with equal sort keys, when
1316    /// sorted with the ID tiebreak, then the ID-less reference sorts last.
1317    #[test]
1318    fn test_id_tiebreak_places_missing_id_last() {
1319        let locale = make_locale();
1320        let sorter = ReferenceSorter::new(&locale);
1321
1322        let with_id = make_reference("r1", "book", "Smith", "Same Title", 2000);
1323        let mut no_id = make_reference("r2", "book", "Smith", "Same Title", 2000);
1324        if let ClassExtension::Monograph(monograph) = no_id.extension_mut() {
1325            monograph.id = None;
1326        }
1327
1328        let refs = vec![&with_id, &no_id];
1329
1330        let sort_spec = GroupSort {
1331            template: vec![GroupSortKey {
1332                key: GroupSortKeyType::Author,
1333                ascending: true,
1334                order: None,
1335                sort_order: Some(NameSortOrder::FamilyGiven),
1336            }],
1337        };
1338
1339        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
1340
1341        assert_eq!(sorted[0].id().unwrap(), "r1");
1342        assert!(sorted[1].id().is_none());
1343    }
1344
1345    /// Given an empty sort template, when sorted with the ID tiebreak, then
1346    /// references keep registry order instead of being reordered by ID alone.
1347    #[test]
1348    fn test_id_tiebreak_with_empty_template_keeps_registry_order() {
1349        let locale = make_locale();
1350        let sorter = ReferenceSorter::new(&locale);
1351
1352        let c = make_reference("r-c", "book", "Smith", "Title C", 2000);
1353        let a = make_reference("r-a", "book", "Jones", "Title A", 2001);
1354
1355        let refs = vec![&c, &a];
1356        let sort_spec = GroupSort { template: vec![] };
1357
1358        let sorted = sorter.sort_references_with_id_tiebreak(refs, &sort_spec);
1359
1360        assert_eq!(sorted[0].id().unwrap(), "r-c");
1361        assert_eq!(sorted[1].id().unwrap(), "r-a");
1362    }
1363
1364    #[test]
1365    fn romanized_author_sort_uses_hidden_sort_as() {
1366        let locale = make_locale();
1367        let config = romanized_config();
1368        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1369        let reference = multilingual_author_reference(
1370            "tolstoy",
1371            "Толстой".into(),
1372            Some("Tolstoy"),
1373            Some("Tolstoĭ"),
1374            Title::Single("War and Peace".to_string()),
1375        );
1376
1377        assert_eq!(
1378            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1379            "Tolstoy"
1380        );
1381    }
1382
1383    #[test]
1384    fn uniform_author_sort_ignores_hidden_sort_as() {
1385        let locale = make_locale();
1386        let sorter = ReferenceSorter::new(&locale);
1387        let reference = multilingual_author_reference(
1388            "tolstoy",
1389            "Толстой".into(),
1390            Some("Tolstoy"),
1391            Some("Tolstoĭ"),
1392            Title::Single("War and Peace".to_string()),
1393        );
1394
1395        assert_eq!(
1396            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1397            "Толстой"
1398        );
1399    }
1400
1401    #[test]
1402    fn romanized_author_sort_falls_back_to_matched_transliteration() {
1403        let locale = make_locale();
1404        let config = romanized_config();
1405        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1406        let reference = multilingual_author_reference(
1407            "tolstoy",
1408            "Толстой".into(),
1409            None,
1410            Some("Tolstoĭ"),
1411            Title::Single("War and Peace".to_string()),
1412        );
1413
1414        assert_eq!(
1415            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1416            "Tolstoĭ"
1417        );
1418    }
1419
1420    #[test]
1421    fn holistic_sort_as_wins_over_part_level_sort_as() {
1422        let locale = make_locale();
1423        let config = romanized_config();
1424        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1425        let family = MultilingualString::Complex(MultilingualComplex {
1426            original: "Толстой".to_string(),
1427            lang: Some("ru".into()),
1428            sort_as: Some("Part Level".to_string()),
1429            transliterations: HashMap::new(),
1430            translations: HashMap::new(),
1431        });
1432        let reference = multilingual_author_reference(
1433            "tolstoy",
1434            family,
1435            Some("Whole Name"),
1436            Some("Tolstoĭ"),
1437            Title::Single("War and Peace".to_string()),
1438        );
1439
1440        assert_eq!(
1441            sorter.extract_author_sort_key(&reference, NameSortOrder::FamilyGiven),
1442            "Whole Name"
1443        );
1444    }
1445
1446    #[test]
1447    fn title_sort_uses_hidden_sort_as_under_romanized_mode() {
1448        let locale = make_locale();
1449        let config = romanized_config();
1450        let sorter = ReferenceSorter::with_bibliography_config(&locale, &config);
1451        let cyrillic = multilingual_author_reference(
1452            "cyrillic-title",
1453            "Smith".into(),
1454            None,
1455            None,
1456            Title::Multilingual(MultilingualComplex {
1457                original: "Война и мир".to_string(),
1458                lang: Some("ru".into()),
1459                sort_as: Some("Academic War and Peace".to_string()),
1460                transliterations: HashMap::new(),
1461                translations: HashMap::new(),
1462            }),
1463        );
1464        let latin = make_reference_no_author("latin-title", "book", "Beta Studies", 2000);
1465
1466        assert_eq!(
1467            title_sort(&sorter, vec![&latin, &cyrillic]),
1468            vec!["cyrillic-title".to_string(), "latin-title".to_string()]
1469        );
1470    }
1471}