use std::cmp::Ordering;
use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::error::Error;
use crate::tld::extension::{Extension, Suffix};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SortKey {
Name,
#[default]
Popularity,
Length,
}
impl SortKey {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Name => "name",
Self::Popularity => "popularity",
Self::Length => "length",
}
}
#[must_use]
pub const fn natural_direction(self) -> SortDirection {
match self {
Self::Popularity => SortDirection::Descending,
Self::Name | Self::Length => SortDirection::Ascending,
}
}
}
impl fmt::Display for SortKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.key())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SortDirection {
#[default]
Ascending,
Descending,
}
impl SortDirection {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Ascending => "asc",
Self::Descending => "desc",
}
}
#[must_use]
const fn orient(self, ordering: Ordering) -> Ordering {
match self {
Self::Ascending => ordering,
Self::Descending => ordering.reverse(),
}
}
}
impl fmt::Display for SortDirection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.key())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sort {
pub key: SortKey,
pub direction: SortDirection,
}
impl Default for Sort {
fn default() -> Self {
Self {
key: SortKey::Popularity,
direction: SortDirection::Descending,
}
}
}
impl Sort {
#[must_use]
pub const fn new(key: SortKey, direction: SortDirection) -> Self {
Self { key, direction }
}
#[must_use]
pub fn compare(&self, left: &Extension, right: &Extension) -> Ordering {
let ordering = match self.key {
SortKey::Name => self.direction.orient(left.suffix.cmp(&right.suffix)),
SortKey::Length => self
.direction
.orient(left.suffix.as_str().len().cmp(&right.suffix.as_str().len())),
SortKey::Popularity => match (left.rank, right.rank) {
(Some(a), Some(b)) => self.direction.orient(b.cmp(&a)),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => Ordering::Equal,
},
};
ordering.then_with(|| left.suffix.cmp(&right.suffix))
}
}
impl FromStr for Sort {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (key_part, dir_part) = match s.split_once(':') {
Some((key, dir)) => (key, Some(dir)),
None => (s, None),
};
let key = match key_part.trim().to_lowercase().as_str() {
"name" | "alpha" | "alphabetical" => SortKey::Name,
"popularity" | "popular" | "rank" | "usage" => SortKey::Popularity,
"length" | "len" => SortKey::Length,
other => {
return Err(Error::FilterInvalid {
setting: "sort key".to_owned(),
value: other.to_owned(),
});
}
};
let direction = match dir_part.map(|part| part.trim().to_lowercase()) {
None => key.natural_direction(),
Some(value) => match value.as_str() {
"asc" | "ascending" | "up" => SortDirection::Ascending,
"desc" | "descending" | "down" => SortDirection::Descending,
other => {
return Err(Error::FilterInvalid {
setting: "sort direction".to_owned(),
value: other.to_owned(),
});
}
},
};
Ok(Self { key, direction })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Depth {
#[default]
Any,
Second,
Third,
}
impl Depth {
#[must_use]
pub const fn key(self) -> &'static str {
match self {
Self::Any => "any",
Self::Second => "second",
Self::Third => "third",
}
}
#[must_use]
pub fn admits(self, suffix: &Suffix) -> bool {
match self {
Self::Any => true,
Self::Second => suffix.label_count() == 1,
Self::Third => suffix.label_count() > 1,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LengthRule {
pub min: Option<usize>,
pub max: Option<usize>,
}
impl LengthRule {
#[must_use]
pub fn admits(&self, suffix: &Suffix) -> bool {
let len = suffix.delegated_label().chars().count();
self.min.is_none_or(|min| len >= min) && self.max.is_none_or(|max| len <= max)
}
}
impl FromStr for LengthRule {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let raw = s.trim();
let invalid = || Error::FilterInvalid {
setting: "length rule".to_owned(),
value: raw.to_owned(),
};
if raw.is_empty() {
return Err(invalid());
}
let parse = |part: &str| -> Result<usize, Error> {
let value = part.parse::<usize>().map_err(|_| invalid())?;
if !(1..=63).contains(&value) {
return Err(invalid());
}
Ok(value)
};
let rule = if let Some(rest) = raw.strip_prefix('-') {
Self {
min: None,
max: Some(parse(rest)?),
}
} else if let Some(rest) = raw.strip_suffix('-') {
Self {
min: Some(parse(rest)?),
max: None,
}
} else if let Some((lo, hi)) = raw.split_once('-') {
Self {
min: Some(parse(lo)?),
max: Some(parse(hi)?),
}
} else {
let exact = parse(raw)?;
Self {
min: Some(exact),
max: Some(exact),
}
};
if let (Some(min), Some(max)) = (rule.min, rule.max)
&& min > max
{
return Err(invalid());
}
Ok(rule)
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Filter {
pub search: Option<String>,
pub depth: Depth,
pub length: Option<LengthRule>,
pub country_codes_only: bool,
pub registrable_only: bool,
pub industries: Vec<String>,
pub regions: Vec<String>,
pub exclude: Vec<Suffix>,
}
impl Filter {
#[must_use]
pub fn registrable() -> Self {
Self {
registrable_only: true,
..Self::default()
}
}
#[must_use]
pub fn admits(&self, ext: &Extension) -> bool {
if self.registrable_only && !ext.registrable {
return false;
}
if self.country_codes_only && !ext.suffix.is_country_code() {
return false;
}
if !self.depth.admits(&ext.suffix) {
return false;
}
if let Some(rule) = self.length
&& !rule.admits(&ext.suffix)
{
return false;
}
if self.exclude.contains(&ext.suffix) {
return false;
}
if !self.industries.is_empty() && !self.industries.iter().any(|key| ext.is_in_industry(key))
{
return false;
}
if !self.regions.is_empty()
&& !self
.regions
.iter()
.any(|key| ext.region.as_deref() == Some(key.as_str()))
{
return false;
}
if let Some(search) = &self.search
&& !Self::text_matches(search, ext)
{
return false;
}
true
}
fn text_matches(search: &str, ext: &Extension) -> bool {
let search = search.trim().trim_start_matches('.').to_lowercase();
if search.is_empty() {
return true;
}
if ext.suffix.as_str().contains(&search) {
return true;
}
if ext
.country
.as_deref()
.is_some_and(|country| country.to_lowercase().contains(&search))
{
return true;
}
if ext
.region
.as_deref()
.is_some_and(|region| region.to_lowercase().contains(&search))
{
return true;
}
ext.industries
.iter()
.any(|industry| industry.to_lowercase().contains(&search))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Page {
pub number: usize,
pub size: usize,
}
impl Page {
pub const DEFAULT_SIZE: usize = 25;
#[must_use]
pub const fn new(number: usize, size: usize) -> Self {
Self {
number: if number == 0 { 1 } else { number },
size: if size == 0 { Self::DEFAULT_SIZE } else { size },
}
}
#[must_use]
pub const fn first(size: usize) -> Self {
Self::new(1, size)
}
#[must_use]
pub const fn offset(&self) -> usize {
(self.number - 1).saturating_mul(self.size)
}
#[must_use]
pub const fn pages_for(&self, item_count: usize) -> usize {
if item_count == 0 {
return 1;
}
item_count.div_ceil(self.size)
}
#[must_use]
pub fn slice<'a, T>(&self, items: &'a [T]) -> &'a [T] {
let total = self.pages_for(items.len());
let number = self.number.min(total);
let start = (number - 1).saturating_mul(self.size).min(items.len());
let end = start.saturating_add(self.size).min(items.len());
items.get(start..end).unwrap_or(&[])
}
#[must_use]
pub const fn next(&self, item_count: usize) -> Option<Self> {
if self.number < self.pages_for(item_count) {
Some(Self {
number: self.number + 1,
size: self.size,
})
} else {
None
}
}
#[must_use]
pub const fn previous(&self) -> Option<Self> {
if self.number > 1 {
Some(Self {
number: self.number - 1,
size: self.size,
})
} else {
None
}
}
}
impl Default for Page {
fn default() -> Self {
Self::first(Self::DEFAULT_SIZE)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tld::extension::ExtensionKind;
fn ext(suffix: &str, rank: Option<u32>) -> Extension {
Extension {
suffix: Suffix::parse(suffix).unwrap(),
kind: ExtensionKind::Generic,
rank,
industries: vec!["tech".to_owned()],
region: None,
country: None,
registrable: true,
repurposed: false,
}
}
#[test]
fn popularity_descending_puts_the_most_used_first() {
let mut items = [ext("xyz", Some(40)), ext("com", Some(1))];
items.sort_by(|a, b| Sort::default().compare(a, b));
assert_eq!(items.first().unwrap().suffix.as_str(), "com");
}
#[test]
fn every_spelling_of_a_sort_field_reaches_the_same_field() {
for spec in ["name", "alpha", "alphabetical"] {
assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Name, "{spec}");
}
for spec in ["popularity", "popular", "rank", "usage"] {
assert_eq!(
spec.parse::<Sort>().unwrap().key,
SortKey::Popularity,
"{spec}"
);
}
for spec in ["length", "len"] {
assert_eq!(spec.parse::<Sort>().unwrap().key, SortKey::Length, "{spec}");
}
}
#[test]
fn every_spelling_of_a_direction_reaches_the_same_order() {
for spec in ["name:asc", "name:ascending", "name:up"] {
assert_eq!(
spec.parse::<Sort>().unwrap().direction,
SortDirection::Ascending,
"{spec}"
);
}
for spec in ["name:desc", "name:descending", "name:down"] {
assert_eq!(
spec.parse::<Sort>().unwrap().direction,
SortDirection::Descending,
"{spec}"
);
}
}
#[test]
fn a_field_given_without_a_direction_takes_the_one_that_reads_best_for_it() {
assert_eq!(
"popularity".parse::<Sort>().unwrap(),
Sort::new(SortKey::Popularity, SortDirection::Descending)
);
assert_eq!(
"name".parse::<Sort>().unwrap(),
Sort::new(SortKey::Name, SortDirection::Ascending)
);
assert_eq!(
"length".parse::<Sort>().unwrap(),
Sort::new(SortKey::Length, SortDirection::Ascending)
);
}
#[test]
fn an_explicit_direction_overrides_the_natural_one() {
assert_eq!(
"popularity:asc".parse::<Sort>().unwrap(),
Sort::new(SortKey::Popularity, SortDirection::Ascending)
);
assert_eq!(
"name:desc".parse::<Sort>().unwrap(),
Sort::new(SortKey::Name, SortDirection::Descending)
);
}
#[test]
fn case_and_surrounding_space_do_not_change_what_is_parsed() {
assert_eq!(
" NAME : DESC ".parse::<Sort>().unwrap(),
Sort::new(SortKey::Name, SortDirection::Descending)
);
assert_eq!(
"Length".parse::<Sort>().unwrap(),
Sort::new(SortKey::Length, SortDirection::Ascending)
);
}
#[test]
fn an_unknown_sort_field_is_refused_and_names_the_setting_and_the_value() {
match "colour".parse::<Sort>() {
Err(Error::FilterInvalid { setting, value }) => {
assert_eq!(setting, "sort key");
assert_eq!(value, "colour");
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn an_unknown_direction_is_refused_and_names_the_setting_and_the_value() {
match "name:sideways".parse::<Sort>() {
Err(Error::FilterInvalid { setting, value }) => {
assert_eq!(setting, "sort direction");
assert_eq!(value, "sideways");
}
other => panic!("expected a refusal, got {other:?}"),
}
}
#[test]
fn a_missing_field_or_direction_is_refused_rather_than_filled_in() {
for spec in ["", ":asc", "name:", "name: "] {
assert!(spec.parse::<Sort>().is_err(), "{spec:?} should be refused");
}
}
#[test]
fn a_refused_sort_carries_the_usage_error_id() {
let error = "colour".parse::<Sort>().unwrap_err();
assert_eq!(error.id(), crate::error::ErrorId::FilterInvalid);
assert_eq!(error.exit_class(), crate::error::ExitClass::Usage);
}
#[test]
fn sorting_by_length_puts_the_shortest_first_and_settles_ties_by_name() {
let mut items = [ext("dev", None), ext("io", None), ext("app", None)];
items.sort_by(|a, b| Sort::new(SortKey::Length, SortDirection::Ascending).compare(a, b));
let names: Vec<&str> = items.iter().map(|ext| ext.suffix.as_str()).collect();
assert_eq!(names, vec!["io", "app", "dev"]);
}
#[test]
fn sorting_by_name_follows_the_direction_it_was_given() {
let mut items = [ext("dev", None), ext("app", None)];
items.sort_by(|a, b| Sort::new(SortKey::Name, SortDirection::Descending).compare(a, b));
assert_eq!(items.first().unwrap().suffix.as_str(), "dev");
}
#[test]
fn an_unranked_extension_sorts_last_whichever_way_the_list_runs() {
for direction in [SortDirection::Ascending, SortDirection::Descending] {
let mut items = [ext("aaa", None), ext("zzz", Some(900))];
items.sort_by(|a, b| Sort::new(SortKey::Popularity, direction).compare(a, b));
assert_eq!(
items.first().unwrap().suffix.as_str(),
"zzz",
"unknown data floated to the top going {direction}"
);
}
}
#[test]
fn a_length_rule_covers_exact_max_min_and_range() {
assert!(
"2".parse::<LengthRule>()
.unwrap()
.admits(&Suffix::parse("io").unwrap())
);
assert!(
!"2".parse::<LengthRule>()
.unwrap()
.admits(&Suffix::parse("com").unwrap())
);
assert!(
"-3".parse::<LengthRule>()
.unwrap()
.admits(&Suffix::parse("com").unwrap())
);
assert!(
"4-".parse::<LengthRule>()
.unwrap()
.admits(&Suffix::parse("shop").unwrap())
);
assert!(
"2-4"
.parse::<LengthRule>()
.unwrap()
.admits(&Suffix::parse("dev").unwrap())
);
assert!("4-2".parse::<LengthRule>().is_err());
assert!("".parse::<LengthRule>().is_err());
}
#[test]
fn a_length_rule_measures_the_delegated_label_only() {
let rule: LengthRule = "2".parse().unwrap();
assert!(rule.admits(&Suffix::parse("co.uk").unwrap()));
}
#[test]
fn a_restricted_zone_is_dropped_by_default() {
let mut restricted = ext("gov.bd", None);
restricted.registrable = false;
assert!(!Filter::registrable().admits(&restricted));
assert!(Filter::default().admits(&restricted));
}
#[test]
fn search_reaches_the_extension_country_region_and_industry() {
let mut bangladesh = ext("bd", None);
bangladesh.country = Some("Bangladesh".to_owned());
bangladesh.region = Some("south-asia".to_owned());
for wanted in ["bd", "bangla", "south", "tech"] {
let filter = Filter {
search: Some(wanted.to_owned()),
..Filter::registrable()
};
assert!(filter.admits(&bangladesh), "{wanted} should match");
}
let filter = Filter {
search: Some("norway".to_owned()),
..Filter::registrable()
};
assert!(!filter.admits(&bangladesh));
}
#[test]
fn paging_cuts_the_list_and_never_runs_off_the_end() {
let items: Vec<u32> = (1..=10).collect();
let page = Page::new(1, 4);
assert_eq!(page.slice(&items), &[1, 2, 3, 4]);
assert_eq!(page.pages_for(items.len()), 3);
let last = Page::new(3, 4);
assert_eq!(last.slice(&items), &[9, 10]);
let past_the_end = Page::new(99, 4);
assert_eq!(past_the_end.slice(&items), &[9, 10]);
}
#[test]
fn paging_walks_forward_and_back_and_stops_at_the_edges() {
let page = Page::new(1, 4);
assert_eq!(page.previous(), None);
let second = page.next(10).unwrap();
assert_eq!(second.number, 2);
assert_eq!(second.previous().unwrap().number, 1);
assert_eq!(Page::new(3, 4).next(10), None);
}
#[test]
fn an_empty_list_still_has_one_page() {
let empty: Vec<u32> = Vec::new();
let page = Page::default();
assert_eq!(page.pages_for(empty.len()), 1);
assert!(page.slice(&empty).is_empty());
}
#[test]
fn a_zero_page_number_or_size_is_clamped_rather_than_panicking() {
let page = Page::new(0, 0);
assert_eq!(page.number, 1);
assert_eq!(page.size, Page::DEFAULT_SIZE);
}
}