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