Skip to main content

reserve_core/tld/
catalog.rs

1use std::collections::{BTreeSet, HashMap};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{Error, Result};
6use crate::tld::extension::{Extension, Suffix, parse_name};
7use crate::tld::group::{Family, Group};
8use crate::tld::selection::{Filter, Sort};
9
10pub const CATALOG_VERSION: u32 = 1;
11
12const BUNDLED: &str = include_str!("../../data/catalog.json");
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Catalog {
16    pub version: u32,
17    pub generated_on: String,
18    pub extensions: Vec<Extension>,
19    pub groups: Vec<Group>,
20    #[serde(skip)]
21    by_suffix: HashMap<String, usize>,
22}
23
24#[derive(Debug, Clone, Default)]
25pub struct SweepPlan {
26    pub group_keys: Vec<String>,
27    pub extensions: Vec<Suffix>,
28    pub filter: Filter,
29    pub sort: Sort,
30}
31
32impl SweepPlan {
33    #[must_use]
34    pub fn is_empty(&self) -> bool {
35        self.group_keys.is_empty() && self.extensions.is_empty()
36    }
37}
38
39impl Catalog {
40    pub fn bundled() -> Result<Self> {
41        Self::from_json(BUNDLED)
42    }
43
44    pub fn from_json(raw: &str) -> Result<Self> {
45        let mut catalog: Self =
46            serde_json::from_str(raw).map_err(|source| Error::CatalogMalformed {
47                source: Box::new(source),
48            })?;
49        catalog.reindex();
50        Ok(catalog)
51    }
52
53    fn reindex(&mut self) {
54        self.by_suffix = self
55            .extensions
56            .iter()
57            .enumerate()
58            .map(|(position, ext)| (ext.suffix.as_str().to_owned(), position))
59            .collect();
60    }
61
62    #[must_use]
63    pub fn extension_count(&self) -> usize {
64        self.extensions.len()
65    }
66
67    #[must_use]
68    pub fn is_empty(&self) -> bool {
69        self.extensions.is_empty()
70    }
71
72    #[must_use]
73    pub fn group(&self, key: &str) -> Option<&Group> {
74        self.groups.iter().find(|group| group.key == key)
75    }
76
77    #[must_use]
78    pub fn groups_in(&self, family: Family) -> Vec<&Group> {
79        let mut found: Vec<&Group> = self
80            .groups
81            .iter()
82            .filter(|group| group.family == family)
83            .collect();
84        found.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.key.cmp(&b.key)));
85        found
86    }
87
88    #[must_use]
89    pub fn group_size(&self, group: &Group) -> usize {
90        self.extensions
91            .iter()
92            .filter(|ext| group.holds(ext))
93            .count()
94    }
95
96    pub fn extensions_for(&self, plan: &SweepPlan) -> Result<Vec<&Extension>> {
97        let mut wanted: BTreeSet<usize> = BTreeSet::new();
98
99        for key in &plan.group_keys {
100            let group = self.group(key).ok_or_else(|| Error::GroupUnknown {
101                // @docgen The key came from the command line and is printed back, so it goes through the same guard every other echo does.
102                name: crate::lookup::scrub(key),
103                closest_groups: self.closest_group_keys(key),
104            })?;
105            for (position, ext) in self.extensions.iter().enumerate() {
106                if group.holds(ext) {
107                    wanted.insert(position);
108                }
109            }
110        }
111
112        let mut named: BTreeSet<usize> = BTreeSet::new();
113        for suffix in &plan.extensions {
114            match self.by_suffix.get(suffix.as_str()) {
115                Some(position) => {
116                    wanted.insert(*position);
117                    named.insert(*position);
118                }
119                None => {
120                    return Err(Error::ExtensionInvalid {
121                        extension: suffix.as_str().to_owned(),
122                    });
123                }
124            }
125        }
126
127        let mut relaxed = plan.filter.clone();
128        relaxed.registrable_only = false;
129
130        // @docgen Naming a zone outright is the user saying they know it is restricted, so the default hiding must not override them.
131        let mut chosen: Vec<&Extension> = wanted
132            .iter()
133            .filter_map(|position| {
134                self.extensions
135                    .get(*position)
136                    .map(|ext| (named.contains(position), ext))
137            })
138            .filter(|(was_named, ext)| {
139                if *was_named {
140                    relaxed.admits(ext)
141                } else {
142                    plan.filter.admits(ext)
143                }
144            })
145            .map(|(_, ext)| ext)
146            .collect();
147
148        if chosen.is_empty() {
149            // @docgen Saying "nothing matched" about a zone the user can see in the catalog reads as a broken search.
150            if plan.filter.registrable_only {
151                let hidden = wanted
152                    .iter()
153                    .filter_map(|position| self.extensions.get(*position))
154                    .filter(|ext| relaxed.admits(ext))
155                    .count();
156                if hidden > 0 {
157                    return Err(Error::CatalogRestrictedOnly { hidden });
158                }
159            }
160            return Err(Error::CatalogEmptySelection);
161        }
162
163        chosen.sort_by(|a, b| plan.sort.compare(a, b));
164        Ok(chosen)
165    }
166
167    /// @docgen The longest known extension wins, so `shop.co.uk` splits at `co.uk` rather than at `uk`.
168    #[must_use]
169    pub fn split_domain(&self, domain: &str) -> Option<(String, Suffix)> {
170        let domain = domain.trim().trim_end_matches('.').to_lowercase();
171        let labels: Vec<&str> = domain.split('.').collect();
172        if labels.len() < 2 {
173            return None;
174        }
175
176        for start in 1..labels.len() {
177            let candidate = labels.get(start..)?.join(".");
178            if self.by_suffix.contains_key(&candidate) {
179                let name = parse_name(labels.get(..start)?.join(".").as_str()).ok()?;
180                let suffix = Suffix::parse(&candidate).ok()?;
181                return Some((name, suffix));
182            }
183        }
184
185        let last = labels.last()?;
186        let name = parse_name(
187            labels
188                .get(..labels.len().checked_sub(1)?)?
189                .join(".")
190                .as_str(),
191        )
192        .ok()?;
193        Some((name, Suffix::parse(last).ok()?))
194    }
195
196    #[must_use]
197    pub fn closest_group_keys(&self, typed: &str) -> Vec<String> {
198        let typed = typed.to_lowercase();
199        let mut scored: Vec<(usize, &str)> = self
200            .groups
201            .iter()
202            .map(|group| (edit_distance(&typed, &group.key), group.key.as_str()))
203            .filter(|(distance, key)| *distance <= 3 || key.contains(&typed))
204            .collect();
205        scored.sort_by_key(|(distance, key)| (*distance, *key));
206        scored
207            .into_iter()
208            .take(3)
209            .map(|(_, key)| key.to_owned())
210            .collect()
211    }
212
213    #[must_use]
214    pub fn industry_keys(&self) -> Vec<String> {
215        let keys: BTreeSet<&str> = self
216            .extensions
217            .iter()
218            .flat_map(|ext| ext.industries.iter().map(String::as_str))
219            .collect();
220        keys.into_iter().map(str::to_owned).collect()
221    }
222
223    #[must_use]
224    pub fn region_keys(&self) -> Vec<String> {
225        let keys: BTreeSet<&str> = self
226            .extensions
227            .iter()
228            .filter_map(|ext| ext.region.as_deref())
229            .collect();
230        keys.into_iter().map(str::to_owned).collect()
231    }
232}
233
234fn edit_distance(left: &str, right: &str) -> usize {
235    let left: Vec<char> = left.chars().collect();
236    let right: Vec<char> = right.chars().collect();
237    if left.is_empty() {
238        return right.len();
239    }
240    if right.is_empty() {
241        return left.len();
242    }
243
244    let mut previous: Vec<usize> = (0..=right.len()).collect();
245    let mut current: Vec<usize> = vec![0; right.len() + 1];
246
247    for (i, left_char) in left.iter().enumerate() {
248        if let Some(slot) = current.first_mut() {
249            *slot = i + 1;
250        }
251        for (j, right_char) in right.iter().enumerate() {
252            let cost = usize::from(left_char != right_char);
253            let deletion = previous
254                .get(j + 1)
255                .copied()
256                .unwrap_or(usize::MAX)
257                .saturating_add(1);
258            let insertion = current
259                .get(j)
260                .copied()
261                .unwrap_or(usize::MAX)
262                .saturating_add(1);
263            let substitution = previous
264                .get(j)
265                .copied()
266                .unwrap_or(usize::MAX)
267                .saturating_add(cost);
268            if let Some(slot) = current.get_mut(j + 1) {
269                *slot = deletion.min(insertion).min(substitution);
270            }
271        }
272        std::mem::swap(&mut previous, &mut current);
273    }
274
275    previous.last().copied().unwrap_or(0)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::tld::selection::{SortDirection, SortKey};
282
283    fn catalog() -> Catalog {
284        Catalog::bundled().expect("the bundled catalog must parse")
285    }
286
287    #[test]
288    fn the_bundled_catalog_loads_and_is_the_expected_schema() {
289        let catalog = catalog();
290        assert_eq!(catalog.version, CATALOG_VERSION);
291        assert!(!catalog.is_empty());
292        assert!(!catalog.groups.is_empty());
293    }
294
295    #[test]
296    fn every_family_has_at_least_one_group() {
297        let catalog = catalog();
298        for family in Family::all() {
299            assert!(
300                !catalog.groups_in(family).is_empty(),
301                "{family} has no groups"
302            );
303        }
304    }
305
306    #[test]
307    fn group_keys_are_unique() {
308        let catalog = catalog();
309        let unique: BTreeSet<&str> = catalog
310            .groups
311            .iter()
312            .map(|group| group.key.as_str())
313            .collect();
314        assert_eq!(unique.len(), catalog.groups.len());
315    }
316
317    #[test]
318    fn extension_suffixes_are_unique() {
319        let catalog = catalog();
320        let unique: BTreeSet<&str> = catalog
321            .extensions
322            .iter()
323            .map(|ext| ext.suffix.as_str())
324            .collect();
325        assert_eq!(unique.len(), catalog.extensions.len());
326    }
327
328    #[test]
329    fn every_group_holds_at_least_one_extension() {
330        let catalog = catalog();
331        for group in &catalog.groups {
332            assert!(
333                catalog.group_size(group) > 0,
334                "group `{}` is empty",
335                group.key
336            );
337        }
338    }
339
340    #[test]
341    fn resolving_a_group_returns_its_members_sorted() {
342        let catalog = catalog();
343        let plan = SweepPlan {
344            group_keys: vec!["tech".to_owned()],
345            sort: Sort::new(SortKey::Name, SortDirection::Ascending),
346            ..SweepPlan::default()
347        };
348        let chosen = catalog.extensions_for(&plan).unwrap();
349        assert!(!chosen.is_empty());
350        let names: Vec<&str> = chosen.iter().map(|ext| ext.suffix.as_str()).collect();
351        let mut sorted = names.clone();
352        sorted.sort_unstable();
353        assert_eq!(names, sorted);
354    }
355
356    #[test]
357    fn groups_and_named_extensions_union_without_duplicates() {
358        let catalog = catalog();
359        let plan = SweepPlan {
360            group_keys: vec!["top-10".to_owned()],
361            extensions: vec![Suffix::parse("com").unwrap()],
362            ..SweepPlan::default()
363        };
364        let chosen = catalog.extensions_for(&plan).unwrap();
365        let com_count = chosen
366            .iter()
367            .filter(|ext| ext.suffix.as_str() == "com")
368            .count();
369        assert_eq!(com_count, 1);
370    }
371
372    #[test]
373    fn an_unknown_group_names_the_closest_match() {
374        let catalog = catalog();
375        let plan = SweepPlan {
376            group_keys: vec!["tec".to_owned()],
377            ..SweepPlan::default()
378        };
379        let error = catalog.extensions_for(&plan).unwrap_err();
380        match error {
381            Error::GroupUnknown { closest_groups, .. } => {
382                assert!(
383                    closest_groups.contains(&"tech".to_owned()),
384                    "got {closest_groups:?}"
385                );
386            }
387            other => panic!("expected GroupUnknown, got {other:?}"),
388        }
389    }
390
391    #[test]
392    fn a_full_domain_splits_at_its_longest_known_extension() {
393        let catalog = catalog();
394        assert_eq!(
395            catalog
396                .split_domain("shop.co.uk")
397                .map(|(name, suffix)| (name, suffix.to_string())),
398            Some(("shop".to_owned(), "co.uk".to_owned()))
399        );
400        assert_eq!(
401            catalog
402                .split_domain("apple.com")
403                .map(|(name, suffix)| (name, suffix.to_string())),
404            Some(("apple".to_owned(), "com".to_owned()))
405        );
406        assert_eq!(
407            catalog
408                .split_domain("example.com.bd")
409                .map(|(name, suffix)| (name, suffix.to_string())),
410            Some(("example".to_owned(), "com.bd".to_owned()))
411        );
412    }
413
414    #[test]
415    fn a_domain_under_an_unknown_extension_still_splits() {
416        let catalog = catalog();
417        assert_eq!(
418            catalog
419                .split_domain("thing.zzznotreal")
420                .map(|(name, suffix)| (name, suffix.to_string())),
421            Some(("thing".to_owned(), "zzznotreal".to_owned()))
422        );
423    }
424
425    #[test]
426    fn a_bare_name_with_no_dot_does_not_split() {
427        assert!(catalog().split_domain("example").is_none());
428        assert!(catalog().split_domain("").is_none());
429    }
430
431    #[test]
432    fn a_trailing_dot_and_case_do_not_change_the_split() {
433        let catalog = catalog();
434        assert_eq!(
435            catalog
436                .split_domain("APPLE.COM.")
437                .map(|(name, suffix)| (name, suffix.to_string())),
438            Some(("apple".to_owned(), "com".to_owned()))
439        );
440    }
441
442    #[test]
443    fn a_catalog_carrying_an_unusable_extension_is_refused_rather_than_loaded() {
444        let usable = r#"{"version":1,"generated_on":"2026-08-19",
445            "extensions":[{"suffix":"com.bd","kind":"country"}],"groups":[]}"#;
446        assert!(Catalog::from_json(usable).is_ok());
447
448        let unusable = r#"{"version":1,"generated_on":"2026-08-19",
449            "extensions":[{"suffix":"-not-a-suffix-","kind":"country"}],"groups":[]}"#;
450        let error = Catalog::from_json(unusable)
451            .expect_err("a suffix the parser refuses must not enter through the catalog");
452        assert_eq!(error.id(), crate::error::ErrorId::CatalogMalformed);
453    }
454
455    #[test]
456    fn edit_distance_is_symmetric_and_zero_on_a_match() {
457        assert_eq!(edit_distance("tech", "tech"), 0);
458        assert_eq!(edit_distance("tech", "tec"), 1);
459        assert_eq!(edit_distance("tec", "tech"), 1);
460        assert_eq!(edit_distance("", "abc"), 3);
461        assert_eq!(edit_distance("abc", ""), 3);
462    }
463
464    #[test]
465    fn a_match_hidden_only_because_it_is_restricted_says_so_and_names_the_flag() {
466        let catalog = Catalog::bundled().expect("the bundled catalog parses");
467        let plan = SweepPlan {
468            group_keys: vec!["everything".to_owned()],
469            filter: Filter {
470                search: Some("bank".to_owned()),
471                ..Filter::registrable()
472            },
473            ..SweepPlan::default()
474        };
475        let error = catalog
476            .extensions_for(&plan)
477            .expect_err("every match is a zone the public cannot register under");
478        assert!(matches!(error, Error::CatalogRestrictedOnly { hidden } if hidden > 0));
479        assert!(error.remedy().contains("--include-restricted"));
480        assert_eq!(error.id().as_str(), "catalog.restricted_only");
481    }
482
483    #[test]
484    fn naming_a_restricted_zone_outright_checks_it_instead_of_hiding_it() {
485        let catalog = Catalog::bundled().expect("the bundled catalog parses");
486        let restricted = catalog
487            .extensions
488            .iter()
489            .find(|ext| !ext.registrable)
490            .map(|ext| ext.suffix.clone())
491            .expect("the catalog carries at least one restricted zone");
492
493        let plan = SweepPlan {
494            extensions: vec![restricted.clone()],
495            filter: Filter::registrable(),
496            ..SweepPlan::default()
497        };
498        let chosen = catalog
499            .extensions_for(&plan)
500            .expect("a zone named outright is checked, restricted or not");
501        assert_eq!(chosen.len(), 1);
502        assert_eq!(chosen[0].suffix, restricted);
503    }
504}