Skip to main content

reserve_core/tld/
group.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::tld::extension::Extension;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9/// @docgen Declaration order is the order the picker draws them in, so the most-asked-for list comes first.
10pub enum Family {
11    Popularity,
12    Curated,
13    Industry,
14    Region,
15}
16
17impl Family {
18    #[must_use]
19    pub const fn key(self) -> &'static str {
20        match self {
21            Self::Popularity => "popularity",
22            Self::Curated => "curated",
23            Self::Industry => "industry",
24            Self::Region => "region",
25        }
26    }
27
28    #[must_use]
29    pub const fn title(self) -> &'static str {
30        match self {
31            Self::Popularity => "By popularity",
32            Self::Curated => "Hand-picked",
33            Self::Industry => "By industry",
34            Self::Region => "By region",
35        }
36    }
37
38    #[must_use]
39    pub const fn all() -> [Self; 4] {
40        [
41            Self::Popularity,
42            Self::Curated,
43            Self::Industry,
44            Self::Region,
45        ]
46    }
47}
48
49impl fmt::Display for Family {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        f.write_str(self.key())
52    }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(tag = "rule", rename_all = "kebab-case")]
57pub enum Selector {
58    Industry { key: String },
59    Region { key: String },
60    TopRank { max_rank: u32 },
61    CountryCodes,
62    Repurposed,
63    Explicit { suffixes: Vec<String> },
64    Registrable,
65    Everything,
66}
67
68impl Selector {
69    #[must_use]
70    pub fn matches(&self, ext: &Extension) -> bool {
71        match self {
72            Self::Industry { key } => ext.is_in_industry(key),
73            Self::Region { key } => ext.region.as_deref() == Some(key.as_str()),
74            Self::TopRank { max_rank } => ext.rank.is_some_and(|rank| rank <= *max_rank),
75            Self::CountryCodes => ext.suffix.is_country_code(),
76            Self::Repurposed => ext.repurposed,
77            Self::Explicit { suffixes } => {
78                suffixes.iter().any(|listed| listed == ext.suffix.as_str())
79            }
80            Self::Registrable => ext.registrable,
81            Self::Everything => true,
82        }
83    }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
87pub struct Group {
88    pub key: String,
89    pub family: Family,
90    pub title: String,
91    pub summary: String,
92    pub selector: Selector,
93    #[serde(default)]
94    pub order: u16,
95}
96
97impl Group {
98    #[must_use]
99    pub fn holds(&self, ext: &Extension) -> bool {
100        self.selector.matches(ext)
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::tld::extension::{ExtensionKind, Suffix};
108
109    #[test]
110    fn the_families_are_offered_most_asked_for_first() {
111        assert_eq!(
112            Family::all().map(Family::title),
113            ["By popularity", "Hand-picked", "By industry", "By region"],
114            "the picker draws them in this order, so the order is part of the product"
115        );
116    }
117
118    #[test]
119    fn the_declared_order_matches_the_offered_order() {
120        let mut sorted = Family::all();
121        sorted.sort_unstable();
122        assert_eq!(
123            sorted,
124            Family::all(),
125            "a derived comparison disagreeing with the drawn order would sort a list the wrong way round"
126        );
127    }
128
129    fn ext(suffix: &str) -> Extension {
130        Extension {
131            suffix: Suffix::parse(suffix).unwrap(),
132            kind: ExtensionKind::Generic,
133            rank: Some(5),
134            industries: vec!["tech".to_owned()],
135            region: None,
136            country: None,
137            registrable: true,
138            repurposed: false,
139        }
140    }
141
142    #[test]
143    fn an_industry_selector_reads_the_industry_tags() {
144        let selector = Selector::Industry {
145            key: "tech".to_owned(),
146        };
147        assert!(selector.matches(&ext("dev")));
148        let selector = Selector::Industry {
149            key: "health".to_owned(),
150        };
151        assert!(!selector.matches(&ext("dev")));
152    }
153
154    #[test]
155    fn top_rank_keeps_only_ranks_at_or_under_the_limit() {
156        assert!(Selector::TopRank { max_rank: 10 }.matches(&ext("dev")));
157        assert!(!Selector::TopRank { max_rank: 3 }.matches(&ext("dev")));
158    }
159
160    #[test]
161    fn country_codes_match_on_the_delegated_label() {
162        let mut uk = ext("co.uk");
163        uk.kind = ExtensionKind::Country;
164        assert!(Selector::CountryCodes.matches(&uk));
165        assert!(!Selector::CountryCodes.matches(&ext("dev")));
166    }
167
168    #[test]
169    fn a_region_selector_keeps_only_the_extensions_of_that_region() {
170        let mut bangladesh = ext("bd");
171        bangladesh.region = Some("south-asia".to_owned());
172
173        assert!(
174            Selector::Region {
175                key: "south-asia".to_owned()
176            }
177            .matches(&bangladesh)
178        );
179        assert!(
180            !Selector::Region {
181                key: "europe".to_owned()
182            }
183            .matches(&bangladesh)
184        );
185        assert!(
186            !Selector::Region {
187                key: "south-asia".to_owned()
188            }
189            .matches(&ext("dev")),
190            "an extension with no region must not fall into one"
191        );
192    }
193
194    #[test]
195    fn a_repurposed_selector_keeps_only_the_zones_marked_repurposed() {
196        let mut repurposed = ext("io");
197        repurposed.repurposed = true;
198
199        assert!(Selector::Repurposed.matches(&repurposed));
200        assert!(!Selector::Repurposed.matches(&ext("dev")));
201    }
202
203    #[test]
204    fn an_explicit_selector_matches_the_whole_suffix_and_never_its_parent() {
205        let selector = Selector::Explicit {
206            suffixes: vec!["com.bd".to_owned()],
207        };
208
209        assert!(selector.matches(&ext("com.bd")));
210        assert!(!selector.matches(&ext("bd")));
211        assert!(
212            !Selector::Explicit {
213                suffixes: Vec::new()
214            }
215            .matches(&ext("com.bd"))
216        );
217    }
218
219    #[test]
220    fn a_registrable_selector_drops_a_zone_the_public_cannot_register_in() {
221        let mut closed = ext("gov.bd");
222        closed.registrable = false;
223
224        assert!(!Selector::Registrable.matches(&closed));
225        assert!(Selector::Registrable.matches(&ext("dev")));
226    }
227
228    #[test]
229    fn everything_takes_every_extension_including_a_closed_one() {
230        let mut closed = ext("gov.bd");
231        closed.registrable = false;
232
233        assert!(Selector::Everything.matches(&closed));
234        assert!(Selector::Everything.matches(&ext("dev")));
235    }
236
237    #[test]
238    fn top_rank_never_admits_an_extension_that_has_no_rank() {
239        let mut unranked = ext("dev");
240        unranked.rank = None;
241
242        assert!(!Selector::TopRank { max_rank: u32::MAX }.matches(&unranked));
243    }
244
245    #[test]
246    fn a_group_answers_with_its_own_selector_and_nothing_else() {
247        let group = Group {
248            key: "south-asia".to_owned(),
249            family: Family::Region,
250            title: "South Asia".to_owned(),
251            summary: "Extensions used across South Asia".to_owned(),
252            selector: Selector::Explicit {
253                suffixes: vec!["bd".to_owned()],
254            },
255            order: 0,
256        };
257
258        assert!(group.holds(&ext("bd")));
259        assert!(!group.holds(&ext("dev")));
260    }
261}