Skip to main content

reserve_core/tld/
selection.rs

1use std::cmp::Ordering;
2use std::fmt;
3use std::str::FromStr;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::Error;
8use crate::tld::extension::{Extension, Suffix};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum SortKey {
13    Name,
14    #[default]
15    Popularity,
16    Length,
17}
18
19impl SortKey {
20    #[must_use]
21    pub const fn key(self) -> &'static str {
22        match self {
23            Self::Name => "name",
24            Self::Popularity => "popularity",
25            Self::Length => "length",
26        }
27    }
28
29    #[must_use]
30    pub const fn natural_direction(self) -> SortDirection {
31        match self {
32            Self::Popularity => SortDirection::Descending,
33            Self::Name | Self::Length => SortDirection::Ascending,
34        }
35    }
36}
37
38impl fmt::Display for SortKey {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.write_str(self.key())
41    }
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
45#[serde(rename_all = "kebab-case")]
46pub enum SortDirection {
47    #[default]
48    Ascending,
49    Descending,
50}
51
52impl SortDirection {
53    #[must_use]
54    pub const fn key(self) -> &'static str {
55        match self {
56            Self::Ascending => "asc",
57            Self::Descending => "desc",
58        }
59    }
60
61    #[must_use]
62    const fn orient(self, ordering: Ordering) -> Ordering {
63        match self {
64            Self::Ascending => ordering,
65            Self::Descending => ordering.reverse(),
66        }
67    }
68}
69
70impl fmt::Display for SortDirection {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        f.write_str(self.key())
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Sort {
78    pub key: SortKey,
79    pub direction: SortDirection,
80}
81
82impl Default for Sort {
83    fn default() -> Self {
84        Self {
85            key: SortKey::Popularity,
86            direction: SortDirection::Descending,
87        }
88    }
89}
90
91impl Sort {
92    #[must_use]
93    pub const fn new(key: SortKey, direction: SortDirection) -> Self {
94        Self { key, direction }
95    }
96
97    /// @docgen A missing rank sorts last in both directions, so unknown data never floats to the top.
98    #[must_use]
99    pub fn compare(&self, left: &Extension, right: &Extension) -> Ordering {
100        let ordering = match self.key {
101            SortKey::Name => self.direction.orient(left.suffix.cmp(&right.suffix)),
102            SortKey::Length => self
103                .direction
104                .orient(left.suffix.as_str().len().cmp(&right.suffix.as_str().len())),
105            SortKey::Popularity => match (left.rank, right.rank) {
106                (Some(a), Some(b)) => self.direction.orient(b.cmp(&a)),
107                (Some(_), None) => Ordering::Less,
108                (None, Some(_)) => Ordering::Greater,
109                (None, None) => Ordering::Equal,
110            },
111        };
112        ordering.then_with(|| left.suffix.cmp(&right.suffix))
113    }
114}
115
116impl FromStr for Sort {
117    type Err = Error;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        let (key_part, dir_part) = match s.split_once(':') {
121            Some((key, dir)) => (key, Some(dir)),
122            None => (s, None),
123        };
124
125        let key = match key_part.trim().to_lowercase().as_str() {
126            "name" | "alpha" | "alphabetical" => SortKey::Name,
127            "popularity" | "popular" | "rank" | "usage" => SortKey::Popularity,
128            "length" | "len" => SortKey::Length,
129            other => {
130                return Err(Error::FilterInvalid {
131                    setting: "sort key".to_owned(),
132                    value: other.to_owned(),
133                });
134            }
135        };
136
137        let direction = match dir_part.map(|part| part.trim().to_lowercase()) {
138            None => key.natural_direction(),
139            Some(value) => match value.as_str() {
140                "asc" | "ascending" | "up" => SortDirection::Ascending,
141                "desc" | "descending" | "down" => SortDirection::Descending,
142                other => {
143                    return Err(Error::FilterInvalid {
144                        setting: "sort direction".to_owned(),
145                        value: other.to_owned(),
146                    });
147                }
148            },
149        };
150
151        Ok(Self { key, direction })
152    }
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
156#[serde(rename_all = "kebab-case")]
157pub enum Depth {
158    #[default]
159    Any,
160    Second,
161    Third,
162}
163
164impl Depth {
165    #[must_use]
166    pub const fn key(self) -> &'static str {
167        match self {
168            Self::Any => "any",
169            Self::Second => "second",
170            Self::Third => "third",
171        }
172    }
173
174    #[must_use]
175    pub fn admits(self, suffix: &Suffix) -> bool {
176        match self {
177            Self::Any => true,
178            Self::Second => suffix.label_count() == 1,
179            Self::Third => suffix.label_count() > 1,
180        }
181    }
182}
183
184/// @docgen Length is measured on the delegated label, so `co.uk` counts as two characters.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
186pub struct LengthRule {
187    pub min: Option<usize>,
188    pub max: Option<usize>,
189}
190
191impl LengthRule {
192    #[must_use]
193    pub fn admits(&self, suffix: &Suffix) -> bool {
194        let len = suffix.delegated_label().chars().count();
195        self.min.is_none_or(|min| len >= min) && self.max.is_none_or(|max| len <= max)
196    }
197}
198
199impl FromStr for LengthRule {
200    type Err = Error;
201
202    fn from_str(s: &str) -> Result<Self, Self::Err> {
203        let raw = s.trim();
204        let invalid = || Error::FilterInvalid {
205            setting: "length rule".to_owned(),
206            value: raw.to_owned(),
207        };
208        if raw.is_empty() {
209            return Err(invalid());
210        }
211
212        // @docgen A label is one to sixty-three characters, so a bound outside that can never match and the whole filter set got the blame.
213        let parse = |part: &str| -> Result<usize, Error> {
214            let value = part.parse::<usize>().map_err(|_| invalid())?;
215            if !(1..=63).contains(&value) {
216                return Err(invalid());
217            }
218            Ok(value)
219        };
220
221        let rule = if let Some(rest) = raw.strip_prefix('-') {
222            Self {
223                min: None,
224                max: Some(parse(rest)?),
225            }
226        } else if let Some(rest) = raw.strip_suffix('-') {
227            Self {
228                min: Some(parse(rest)?),
229                max: None,
230            }
231        } else if let Some((lo, hi)) = raw.split_once('-') {
232            Self {
233                min: Some(parse(lo)?),
234                max: Some(parse(hi)?),
235            }
236        } else {
237            let exact = parse(raw)?;
238            Self {
239                min: Some(exact),
240                max: Some(exact),
241            }
242        };
243
244        if let (Some(min), Some(max)) = (rule.min, rule.max)
245            && min > max
246        {
247            return Err(invalid());
248        }
249        Ok(rule)
250    }
251}
252
253#[derive(Debug, Clone, Default, PartialEq)]
254pub struct Filter {
255    pub search: Option<String>,
256    pub depth: Depth,
257    pub length: Option<LengthRule>,
258    pub country_codes_only: bool,
259    pub registrable_only: bool,
260    pub industries: Vec<String>,
261    pub regions: Vec<String>,
262    pub exclude: Vec<Suffix>,
263}
264
265impl Filter {
266    #[must_use]
267    pub fn registrable() -> Self {
268        Self {
269            registrable_only: true,
270            ..Self::default()
271        }
272    }
273
274    #[must_use]
275    pub fn admits(&self, ext: &Extension) -> bool {
276        if self.registrable_only && !ext.registrable {
277            return false;
278        }
279        if self.country_codes_only && !ext.suffix.is_country_code() {
280            return false;
281        }
282        if !self.depth.admits(&ext.suffix) {
283            return false;
284        }
285        if let Some(rule) = self.length
286            && !rule.admits(&ext.suffix)
287        {
288            return false;
289        }
290        if self.exclude.contains(&ext.suffix) {
291            return false;
292        }
293        if !self.industries.is_empty() && !self.industries.iter().any(|key| ext.is_in_industry(key))
294        {
295            return false;
296        }
297        if !self.regions.is_empty()
298            && !self
299                .regions
300                .iter()
301                .any(|key| ext.region.as_deref() == Some(key.as_str()))
302        {
303            return false;
304        }
305
306        if let Some(search) = &self.search
307            && !Self::text_matches(search, ext)
308        {
309            return false;
310        }
311
312        true
313    }
314
315    fn text_matches(search: &str, ext: &Extension) -> bool {
316        let search = search.trim().trim_start_matches('.').to_lowercase();
317        if search.is_empty() {
318            return true;
319        }
320        if ext.suffix.as_str().contains(&search) {
321            return true;
322        }
323        if ext
324            .country
325            .as_deref()
326            .is_some_and(|country| country.to_lowercase().contains(&search))
327        {
328            return true;
329        }
330        if ext
331            .region
332            .as_deref()
333            .is_some_and(|region| region.to_lowercase().contains(&search))
334        {
335            return true;
336        }
337        ext.industries
338            .iter()
339            .any(|industry| industry.to_lowercase().contains(&search))
340    }
341}
342
343/// @docgen Page numbers start at 1 because that is what a person types, so `offset` subtracts one.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct Page {
346    pub number: usize,
347    pub size: usize,
348}
349
350impl Page {
351    pub const DEFAULT_SIZE: usize = 25;
352
353    #[must_use]
354    pub const fn new(number: usize, size: usize) -> Self {
355        Self {
356            number: if number == 0 { 1 } else { number },
357            size: if size == 0 { Self::DEFAULT_SIZE } else { size },
358        }
359    }
360
361    #[must_use]
362    pub const fn first(size: usize) -> Self {
363        Self::new(1, size)
364    }
365
366    #[must_use]
367    pub const fn offset(&self) -> usize {
368        (self.number - 1).saturating_mul(self.size)
369    }
370
371    #[must_use]
372    pub const fn pages_for(&self, item_count: usize) -> usize {
373        if item_count == 0 {
374            return 1;
375        }
376        item_count.div_ceil(self.size)
377    }
378
379    #[must_use]
380    pub fn slice<'a, T>(&self, items: &'a [T]) -> &'a [T] {
381        let total = self.pages_for(items.len());
382        let number = self.number.min(total);
383        let start = (number - 1).saturating_mul(self.size).min(items.len());
384        let end = start.saturating_add(self.size).min(items.len());
385        items.get(start..end).unwrap_or(&[])
386    }
387
388    #[must_use]
389    pub const fn next(&self, item_count: usize) -> Option<Self> {
390        if self.number < self.pages_for(item_count) {
391            Some(Self {
392                number: self.number + 1,
393                size: self.size,
394            })
395        } else {
396            None
397        }
398    }
399
400    #[must_use]
401    pub const fn previous(&self) -> Option<Self> {
402        if self.number > 1 {
403            Some(Self {
404                number: self.number - 1,
405                size: self.size,
406            })
407        } else {
408            None
409        }
410    }
411}
412
413impl Default for Page {
414    fn default() -> Self {
415        Self::first(Self::DEFAULT_SIZE)
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::tld::extension::ExtensionKind;
423
424    fn ext(suffix: &str, rank: Option<u32>) -> Extension {
425        Extension {
426            suffix: Suffix::parse(suffix).unwrap(),
427            kind: ExtensionKind::Generic,
428            rank,
429            industries: vec!["tech".to_owned()],
430            region: None,
431            country: None,
432            registrable: true,
433            repurposed: false,
434        }
435    }
436
437    #[test]
438    fn popularity_descending_puts_the_most_used_first() {
439        let mut items = [ext("xyz", Some(40)), ext("com", Some(1))];
440        items.sort_by(|a, b| Sort::default().compare(a, b));
441        assert_eq!(items.first().unwrap().suffix.as_str(), "com");
442    }
443
444    #[test]
445    fn every_spelling_of_a_sort_field_reaches_the_same_field() {
446        for spec in ["name", "alpha", "alphabetical"] {
447            assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Name, "{spec}");
448        }
449        for spec in ["popularity", "popular", "rank", "usage"] {
450            assert_eq!(
451                spec.parse::<Sort>().unwrap().key,
452                SortKey::Popularity,
453                "{spec}"
454            );
455        }
456        for spec in ["length", "len"] {
457            assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Length, "{spec}");
458        }
459    }
460
461    #[test]
462    fn every_spelling_of_a_direction_reaches_the_same_order() {
463        for spec in ["name:asc", "name:ascending", "name:up"] {
464            assert_eq!(
465                spec.parse::<Sort>().unwrap().direction,
466                SortDirection::Ascending,
467                "{spec}"
468            );
469        }
470        for spec in ["name:desc", "name:descending", "name:down"] {
471            assert_eq!(
472                spec.parse::<Sort>().unwrap().direction,
473                SortDirection::Descending,
474                "{spec}"
475            );
476        }
477    }
478
479    #[test]
480    fn a_field_given_without_a_direction_takes_the_one_that_reads_best_for_it() {
481        assert_eq!(
482            "popularity".parse::<Sort>().unwrap(),
483            Sort::new(SortKey::Popularity, SortDirection::Descending)
484        );
485        assert_eq!(
486            "name".parse::<Sort>().unwrap(),
487            Sort::new(SortKey::Name, SortDirection::Ascending)
488        );
489        assert_eq!(
490            "length".parse::<Sort>().unwrap(),
491            Sort::new(SortKey::Length, SortDirection::Ascending)
492        );
493    }
494
495    #[test]
496    fn an_explicit_direction_overrides_the_natural_one() {
497        assert_eq!(
498            "popularity:asc".parse::<Sort>().unwrap(),
499            Sort::new(SortKey::Popularity, SortDirection::Ascending)
500        );
501        assert_eq!(
502            "name:desc".parse::<Sort>().unwrap(),
503            Sort::new(SortKey::Name, SortDirection::Descending)
504        );
505    }
506
507    #[test]
508    fn case_and_surrounding_space_do_not_change_what_is_parsed() {
509        assert_eq!(
510            "  NAME : DESC  ".parse::<Sort>().unwrap(),
511            Sort::new(SortKey::Name, SortDirection::Descending)
512        );
513        assert_eq!(
514            "Length".parse::<Sort>().unwrap(),
515            Sort::new(SortKey::Length, SortDirection::Ascending)
516        );
517    }
518
519    #[test]
520    fn an_unknown_sort_field_is_refused_and_names_the_setting_and_the_value() {
521        match "colour".parse::<Sort>() {
522            Err(Error::FilterInvalid { setting, value }) => {
523                assert_eq!(setting, "sort key");
524                assert_eq!(value, "colour");
525            }
526            other => panic!("expected a refusal, got {other:?}"),
527        }
528    }
529
530    #[test]
531    fn an_unknown_direction_is_refused_and_names_the_setting_and_the_value() {
532        match "name:sideways".parse::<Sort>() {
533            Err(Error::FilterInvalid { setting, value }) => {
534                assert_eq!(setting, "sort direction");
535                assert_eq!(value, "sideways");
536            }
537            other => panic!("expected a refusal, got {other:?}"),
538        }
539    }
540
541    #[test]
542    fn a_missing_field_or_direction_is_refused_rather_than_filled_in() {
543        for spec in ["", ":asc", "name:", "name:  "] {
544            assert!(spec.parse::<Sort>().is_err(), "{spec:?} should be refused");
545        }
546    }
547
548    #[test]
549    fn a_refused_sort_carries_the_usage_error_id() {
550        let error = "colour".parse::<Sort>().unwrap_err();
551        assert_eq!(error.id(), crate::error::ErrorId::FilterInvalid);
552        assert_eq!(error.exit_class(), crate::error::ExitClass::Usage);
553    }
554
555    #[test]
556    fn sorting_by_length_puts_the_shortest_first_and_settles_ties_by_name() {
557        let mut items = [ext("dev", None), ext("io", None), ext("app", None)];
558        items.sort_by(|a, b| Sort::new(SortKey::Length, SortDirection::Ascending).compare(a, b));
559        let names: Vec<&str> = items.iter().map(|ext| ext.suffix.as_str()).collect();
560        assert_eq!(names, vec!["io", "app", "dev"]);
561    }
562
563    #[test]
564    fn sorting_by_name_follows_the_direction_it_was_given() {
565        let mut items = [ext("dev", None), ext("app", None)];
566        items.sort_by(|a, b| Sort::new(SortKey::Name, SortDirection::Descending).compare(a, b));
567        assert_eq!(items.first().unwrap().suffix.as_str(), "dev");
568    }
569
570    #[test]
571    fn an_unranked_extension_sorts_last_whichever_way_the_list_runs() {
572        for direction in [SortDirection::Ascending, SortDirection::Descending] {
573            let mut items = [ext("aaa", None), ext("zzz", Some(900))];
574            items.sort_by(|a, b| Sort::new(SortKey::Popularity, direction).compare(a, b));
575            assert_eq!(
576                items.first().unwrap().suffix.as_str(),
577                "zzz",
578                "unknown data floated to the top going {direction}"
579            );
580        }
581    }
582
583    #[test]
584    fn a_length_rule_covers_exact_max_min_and_range() {
585        assert!(
586            "2".parse::<LengthRule>()
587                .unwrap()
588                .admits(&Suffix::parse("io").unwrap())
589        );
590        assert!(
591            !"2".parse::<LengthRule>()
592                .unwrap()
593                .admits(&Suffix::parse("com").unwrap())
594        );
595        assert!(
596            "-3".parse::<LengthRule>()
597                .unwrap()
598                .admits(&Suffix::parse("com").unwrap())
599        );
600        assert!(
601            "4-".parse::<LengthRule>()
602                .unwrap()
603                .admits(&Suffix::parse("shop").unwrap())
604        );
605        assert!(
606            "2-4"
607                .parse::<LengthRule>()
608                .unwrap()
609                .admits(&Suffix::parse("dev").unwrap())
610        );
611        assert!("4-2".parse::<LengthRule>().is_err());
612        assert!("".parse::<LengthRule>().is_err());
613    }
614
615    #[test]
616    fn a_length_rule_measures_the_delegated_label_only() {
617        let rule: LengthRule = "2".parse().unwrap();
618        assert!(rule.admits(&Suffix::parse("co.uk").unwrap()));
619    }
620
621    #[test]
622    fn a_restricted_zone_is_dropped_by_default() {
623        let mut restricted = ext("gov.bd", None);
624        restricted.registrable = false;
625        assert!(!Filter::registrable().admits(&restricted));
626        assert!(Filter::default().admits(&restricted));
627    }
628
629    #[test]
630    fn search_reaches_the_extension_country_region_and_industry() {
631        let mut bangladesh = ext("bd", None);
632        bangladesh.country = Some("Bangladesh".to_owned());
633        bangladesh.region = Some("south-asia".to_owned());
634
635        for wanted in ["bd", "bangla", "south", "tech"] {
636            let filter = Filter {
637                search: Some(wanted.to_owned()),
638                ..Filter::registrable()
639            };
640            assert!(filter.admits(&bangladesh), "{wanted} should match");
641        }
642
643        let filter = Filter {
644            search: Some("norway".to_owned()),
645            ..Filter::registrable()
646        };
647        assert!(!filter.admits(&bangladesh));
648    }
649
650    #[test]
651    fn paging_cuts_the_list_and_never_runs_off_the_end() {
652        let items: Vec<u32> = (1..=10).collect();
653        let page = Page::new(1, 4);
654        assert_eq!(page.slice(&items), &[1, 2, 3, 4]);
655        assert_eq!(page.pages_for(items.len()), 3);
656
657        let last = Page::new(3, 4);
658        assert_eq!(last.slice(&items), &[9, 10]);
659
660        let past_the_end = Page::new(99, 4);
661        assert_eq!(past_the_end.slice(&items), &[9, 10]);
662    }
663
664    #[test]
665    fn paging_walks_forward_and_back_and_stops_at_the_edges() {
666        let page = Page::new(1, 4);
667        assert_eq!(page.previous(), None);
668        let second = page.next(10).unwrap();
669        assert_eq!(second.number, 2);
670        assert_eq!(second.previous().unwrap().number, 1);
671        assert_eq!(Page::new(3, 4).next(10), None);
672    }
673
674    #[test]
675    fn an_empty_list_still_has_one_page() {
676        let empty: Vec<u32> = Vec::new();
677        let page = Page::default();
678        assert_eq!(page.pages_for(empty.len()), 1);
679        assert!(page.slice(&empty).is_empty());
680    }
681
682    #[test]
683    fn a_zero_page_number_or_size_is_clamped_rather_than_panicking() {
684        let page = Page::new(0, 0);
685        assert_eq!(page.number, 1);
686        assert_eq!(page.size, Page::DEFAULT_SIZE);
687    }
688}