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