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