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(|d| d.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        let parse =
213            |part: &str| -> Result<usize, Error> { part.parse::<usize>().map_err(|_| invalid()) };
214
215        let rule = if let Some(rest) = raw.strip_prefix('-') {
216            Self {
217                min: None,
218                max: Some(parse(rest)?),
219            }
220        } else if let Some(rest) = raw.strip_suffix('-') {
221            Self {
222                min: Some(parse(rest)?),
223                max: None,
224            }
225        } else if let Some((lo, hi)) = raw.split_once('-') {
226            Self {
227                min: Some(parse(lo)?),
228                max: Some(parse(hi)?),
229            }
230        } else {
231            let exact = parse(raw)?;
232            Self {
233                min: Some(exact),
234                max: Some(exact),
235            }
236        };
237
238        if let (Some(min), Some(max)) = (rule.min, rule.max)
239            && min > max
240        {
241            return Err(invalid());
242        }
243        Ok(rule)
244    }
245}
246
247#[derive(Debug, Clone, Default, PartialEq)]
248pub struct Filter {
249    pub search: Option<String>,
250    pub depth: Depth,
251    pub length: Option<LengthRule>,
252    pub country_codes_only: bool,
253    pub registrable_only: bool,
254    pub industries: Vec<String>,
255    pub regions: Vec<String>,
256    pub exclude: Vec<Suffix>,
257}
258
259impl Filter {
260    #[must_use]
261    pub fn registrable() -> Self {
262        Self {
263            registrable_only: true,
264            ..Self::default()
265        }
266    }
267
268    #[must_use]
269    pub fn admits(&self, ext: &Extension) -> bool {
270        if self.registrable_only && !ext.registrable {
271            return false;
272        }
273        if self.country_codes_only && !ext.suffix.is_country_code() {
274            return false;
275        }
276        if !self.depth.admits(&ext.suffix) {
277            return false;
278        }
279        if let Some(rule) = self.length
280            && !rule.admits(&ext.suffix)
281        {
282            return false;
283        }
284        if self.exclude.contains(&ext.suffix) {
285            return false;
286        }
287        if !self.industries.is_empty() && !self.industries.iter().any(|key| ext.is_in_industry(key))
288        {
289            return false;
290        }
291        if !self.regions.is_empty()
292            && !self
293                .regions
294                .iter()
295                .any(|key| ext.region.as_deref() == Some(key.as_str()))
296        {
297            return false;
298        }
299
300        if let Some(needle) = &self.search
301            && !Self::text_matches(needle, ext)
302        {
303            return false;
304        }
305
306        true
307    }
308
309    fn text_matches(needle: &str, ext: &Extension) -> bool {
310        let needle = needle.trim().trim_start_matches('.').to_lowercase();
311        if needle.is_empty() {
312            return true;
313        }
314        if ext.suffix.as_str().contains(&needle) {
315            return true;
316        }
317        if ext
318            .country
319            .as_deref()
320            .is_some_and(|c| c.to_lowercase().contains(&needle))
321        {
322            return true;
323        }
324        if ext
325            .region
326            .as_deref()
327            .is_some_and(|r| r.to_lowercase().contains(&needle))
328        {
329            return true;
330        }
331        ext.industries
332            .iter()
333            .any(|i| i.to_lowercase().contains(&needle))
334    }
335}
336
337/// @docgen Page numbers start at 1 because that is what a person types, so `offset` subtracts one.
338#[derive(Debug, Clone, Copy, PartialEq, Eq)]
339pub struct Page {
340    pub number: usize,
341    pub size: usize,
342}
343
344impl Page {
345    pub const DEFAULT_SIZE: usize = 25;
346
347    #[must_use]
348    pub const fn new(number: usize, size: usize) -> Self {
349        Self {
350            number: if number == 0 { 1 } else { number },
351            size: if size == 0 { Self::DEFAULT_SIZE } else { size },
352        }
353    }
354
355    #[must_use]
356    pub const fn first(size: usize) -> Self {
357        Self::new(1, size)
358    }
359
360    #[must_use]
361    pub const fn offset(&self) -> usize {
362        (self.number - 1).saturating_mul(self.size)
363    }
364
365    #[must_use]
366    pub const fn pages_for(&self, item_count: usize) -> usize {
367        if item_count == 0 {
368            return 1;
369        }
370        item_count.div_ceil(self.size)
371    }
372
373    #[must_use]
374    pub fn slice<'a, T>(&self, items: &'a [T]) -> &'a [T] {
375        let total = self.pages_for(items.len());
376        let number = self.number.min(total);
377        let start = (number - 1).saturating_mul(self.size).min(items.len());
378        let end = start.saturating_add(self.size).min(items.len());
379        items.get(start..end).unwrap_or(&[])
380    }
381
382    #[must_use]
383    pub const fn next(&self, item_count: usize) -> Option<Self> {
384        if self.number < self.pages_for(item_count) {
385            Some(Self {
386                number: self.number + 1,
387                size: self.size,
388            })
389        } else {
390            None
391        }
392    }
393
394    #[must_use]
395    pub const fn previous(&self) -> Option<Self> {
396        if self.number > 1 {
397            Some(Self {
398                number: self.number - 1,
399                size: self.size,
400            })
401        } else {
402            None
403        }
404    }
405}
406
407impl Default for Page {
408    fn default() -> Self {
409        Self::first(Self::DEFAULT_SIZE)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::tld::extension::ExtensionKind;
417
418    fn ext(suffix: &str, rank: Option<u32>) -> Extension {
419        Extension {
420            suffix: Suffix::parse(suffix).unwrap(),
421            kind: ExtensionKind::Generic,
422            rank,
423            industries: vec!["tech".to_owned()],
424            region: None,
425            country: None,
426            registrable: true,
427            repurposed: false,
428        }
429    }
430
431    #[test]
432    fn popularity_descending_puts_the_most_used_first() {
433        let mut items = [ext("xyz", Some(40)), ext("com", Some(1))];
434        items.sort_by(|a, b| Sort::default().compare(a, b));
435        assert_eq!(items.first().unwrap().suffix.as_str(), "com");
436    }
437
438    #[test]
439    fn every_spelling_of_a_sort_field_reaches_the_same_field() {
440        for spec in ["name", "alpha", "alphabetical"] {
441            assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Name, "{spec}");
442        }
443        for spec in ["popularity", "popular", "rank", "usage"] {
444            assert_eq!(
445                spec.parse::<Sort>().unwrap().key,
446                SortKey::Popularity,
447                "{spec}"
448            );
449        }
450        for spec in ["length", "len"] {
451            assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Length, "{spec}");
452        }
453    }
454
455    #[test]
456    fn every_spelling_of_a_direction_reaches_the_same_order() {
457        for spec in ["name:asc", "name:ascending", "name:up"] {
458            assert_eq!(
459                spec.parse::<Sort>().unwrap().direction,
460                SortDirection::Ascending,
461                "{spec}"
462            );
463        }
464        for spec in ["name:desc", "name:descending", "name:down"] {
465            assert_eq!(
466                spec.parse::<Sort>().unwrap().direction,
467                SortDirection::Descending,
468                "{spec}"
469            );
470        }
471    }
472
473    #[test]
474    fn a_field_given_without_a_direction_takes_the_one_that_reads_best_for_it() {
475        assert_eq!(
476            "popularity".parse::<Sort>().unwrap(),
477            Sort::new(SortKey::Popularity, SortDirection::Descending)
478        );
479        assert_eq!(
480            "name".parse::<Sort>().unwrap(),
481            Sort::new(SortKey::Name, SortDirection::Ascending)
482        );
483        assert_eq!(
484            "length".parse::<Sort>().unwrap(),
485            Sort::new(SortKey::Length, SortDirection::Ascending)
486        );
487    }
488
489    #[test]
490    fn an_explicit_direction_overrides_the_natural_one() {
491        assert_eq!(
492            "popularity:asc".parse::<Sort>().unwrap(),
493            Sort::new(SortKey::Popularity, SortDirection::Ascending)
494        );
495        assert_eq!(
496            "name:desc".parse::<Sort>().unwrap(),
497            Sort::new(SortKey::Name, SortDirection::Descending)
498        );
499    }
500
501    #[test]
502    fn case_and_surrounding_space_do_not_change_what_is_parsed() {
503        assert_eq!(
504            "  NAME : DESC  ".parse::<Sort>().unwrap(),
505            Sort::new(SortKey::Name, SortDirection::Descending)
506        );
507        assert_eq!(
508            "Length".parse::<Sort>().unwrap(),
509            Sort::new(SortKey::Length, SortDirection::Ascending)
510        );
511    }
512
513    #[test]
514    fn an_unknown_sort_field_is_refused_and_names_the_setting_and_the_value() {
515        match "colour".parse::<Sort>() {
516            Err(Error::FilterInvalid { setting, value }) => {
517                assert_eq!(setting, "sort key");
518                assert_eq!(value, "colour");
519            }
520            other => panic!("expected a refusal, got {other:?}"),
521        }
522    }
523
524    #[test]
525    fn an_unknown_direction_is_refused_and_names_the_setting_and_the_value() {
526        match "name:sideways".parse::<Sort>() {
527            Err(Error::FilterInvalid { setting, value }) => {
528                assert_eq!(setting, "sort direction");
529                assert_eq!(value, "sideways");
530            }
531            other => panic!("expected a refusal, got {other:?}"),
532        }
533    }
534
535    #[test]
536    fn a_missing_field_or_direction_is_refused_rather_than_filled_in() {
537        for spec in ["", ":asc", "name:", "name:  "] {
538            assert!(spec.parse::<Sort>().is_err(), "{spec:?} should be refused");
539        }
540    }
541
542    #[test]
543    fn a_refused_sort_carries_the_usage_error_id() {
544        let error = "colour".parse::<Sort>().unwrap_err();
545        assert_eq!(error.id(), crate::error::ErrorId::FilterInvalid);
546        assert_eq!(error.exit_class(), crate::error::ExitClass::Usage);
547    }
548
549    #[test]
550    fn sorting_by_length_puts_the_shortest_first_and_settles_ties_by_name() {
551        let mut items = [ext("dev", None), ext("io", None), ext("app", None)];
552        items.sort_by(|a, b| Sort::new(SortKey::Length, SortDirection::Ascending).compare(a, b));
553        let names: Vec<&str> = items.iter().map(|e| e.suffix.as_str()).collect();
554        assert_eq!(names, vec!["io", "app", "dev"]);
555    }
556
557    #[test]
558    fn sorting_by_name_follows_the_direction_it_was_given() {
559        let mut items = [ext("dev", None), ext("app", None)];
560        items.sort_by(|a, b| Sort::new(SortKey::Name, SortDirection::Descending).compare(a, b));
561        assert_eq!(items.first().unwrap().suffix.as_str(), "dev");
562    }
563
564    #[test]
565    fn an_unranked_extension_sorts_last_whichever_way_the_list_runs() {
566        for direction in [SortDirection::Ascending, SortDirection::Descending] {
567            let mut items = [ext("aaa", None), ext("zzz", Some(900))];
568            items.sort_by(|a, b| Sort::new(SortKey::Popularity, direction).compare(a, b));
569            assert_eq!(
570                items.first().unwrap().suffix.as_str(),
571                "zzz",
572                "unknown data floated to the top going {direction}"
573            );
574        }
575    }
576
577    #[test]
578    fn a_length_rule_covers_exact_max_min_and_range() {
579        assert!(
580            "2".parse::<LengthRule>()
581                .unwrap()
582                .admits(&Suffix::parse("io").unwrap())
583        );
584        assert!(
585            !"2".parse::<LengthRule>()
586                .unwrap()
587                .admits(&Suffix::parse("com").unwrap())
588        );
589        assert!(
590            "-3".parse::<LengthRule>()
591                .unwrap()
592                .admits(&Suffix::parse("com").unwrap())
593        );
594        assert!(
595            "4-".parse::<LengthRule>()
596                .unwrap()
597                .admits(&Suffix::parse("shop").unwrap())
598        );
599        assert!(
600            "2-4"
601                .parse::<LengthRule>()
602                .unwrap()
603                .admits(&Suffix::parse("dev").unwrap())
604        );
605        assert!("4-2".parse::<LengthRule>().is_err());
606        assert!("".parse::<LengthRule>().is_err());
607    }
608
609    #[test]
610    fn a_length_rule_measures_the_delegated_label_only() {
611        let rule: LengthRule = "2".parse().unwrap();
612        assert!(rule.admits(&Suffix::parse("co.uk").unwrap()));
613    }
614
615    #[test]
616    fn a_restricted_zone_is_dropped_by_default() {
617        let mut restricted = ext("gov.bd", None);
618        restricted.registrable = false;
619        assert!(!Filter::registrable().admits(&restricted));
620        assert!(Filter::default().admits(&restricted));
621    }
622
623    #[test]
624    fn search_reaches_the_extension_country_region_and_industry() {
625        let mut item = ext("bd", None);
626        item.country = Some("Bangladesh".to_owned());
627        item.region = Some("south-asia".to_owned());
628
629        for needle in ["bd", "bangla", "south", "tech"] {
630            let filter = Filter {
631                search: Some(needle.to_owned()),
632                ..Filter::registrable()
633            };
634            assert!(filter.admits(&item), "{needle} should match");
635        }
636
637        let filter = Filter {
638            search: Some("norway".to_owned()),
639            ..Filter::registrable()
640        };
641        assert!(!filter.admits(&item));
642    }
643
644    #[test]
645    fn paging_cuts_the_list_and_never_runs_off_the_end() {
646        let items: Vec<u32> = (1..=10).collect();
647        let page = Page::new(1, 4);
648        assert_eq!(page.slice(&items), &[1, 2, 3, 4]);
649        assert_eq!(page.pages_for(items.len()), 3);
650
651        let last = Page::new(3, 4);
652        assert_eq!(last.slice(&items), &[9, 10]);
653
654        let past_the_end = Page::new(99, 4);
655        assert_eq!(past_the_end.slice(&items), &[9, 10]);
656    }
657
658    #[test]
659    fn paging_walks_forward_and_back_and_stops_at_the_edges() {
660        let page = Page::new(1, 4);
661        assert_eq!(page.previous(), None);
662        let second = page.next(10).unwrap();
663        assert_eq!(second.number, 2);
664        assert_eq!(second.previous().unwrap().number, 1);
665        assert_eq!(Page::new(3, 4).next(10), None);
666    }
667
668    #[test]
669    fn an_empty_list_still_has_one_page() {
670        let empty: Vec<u32> = Vec::new();
671        let page = Page::default();
672        assert_eq!(page.pages_for(empty.len()), 1);
673        assert!(page.slice(&empty).is_empty());
674    }
675
676    #[test]
677    fn a_zero_page_number_or_size_is_clamped_rather_than_panicking() {
678        let page = Page::new(0, 0);
679        assert_eq!(page.number, 1);
680        assert_eq!(page.size, Page::DEFAULT_SIZE);
681    }
682}