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 #[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#[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 = |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#[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}