use std::collections::{BTreeSet, HashMap};
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
use crate::tld::extension::{Extension, Suffix, parse_name};
use crate::tld::group::{Family, Group};
use crate::tld::selection::{Filter, Sort};
pub const CATALOG_VERSION: u32 = 1;
const BUNDLED: &str = include_str!("../../data/catalog.json");
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Catalog {
pub version: u32,
pub generated_on: String,
pub extensions: Vec<Extension>,
pub groups: Vec<Group>,
#[serde(skip)]
by_suffix: HashMap<String, usize>,
}
#[derive(Debug, Clone, Default)]
pub struct SweepPlan {
pub group_keys: Vec<String>,
pub extensions: Vec<Suffix>,
pub filter: Filter,
pub sort: Sort,
}
impl SweepPlan {
#[must_use]
pub fn is_empty(&self) -> bool {
self.group_keys.is_empty() && self.extensions.is_empty()
}
}
impl Catalog {
pub fn bundled() -> Result<Self> {
Self::from_json(BUNDLED)
}
pub fn from_json(raw: &str) -> Result<Self> {
let mut catalog: Self =
serde_json::from_str(raw).map_err(|source| Error::CatalogMalformed {
source: Box::new(source),
})?;
catalog.reindex();
Ok(catalog)
}
fn reindex(&mut self) {
self.by_suffix = self
.extensions
.iter()
.enumerate()
.map(|(position, ext)| (ext.suffix.as_str().to_owned(), position))
.collect();
}
#[must_use]
pub fn extension_count(&self) -> usize {
self.extensions.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.extensions.is_empty()
}
#[must_use]
pub fn group(&self, key: &str) -> Option<&Group> {
self.groups.iter().find(|group| group.key == key)
}
#[must_use]
pub fn groups_in(&self, family: Family) -> Vec<&Group> {
let mut found: Vec<&Group> = self
.groups
.iter()
.filter(|group| group.family == family)
.collect();
found.sort_by(|a, b| a.order.cmp(&b.order).then_with(|| a.key.cmp(&b.key)));
found
}
#[must_use]
pub fn group_size(&self, group: &Group) -> usize {
self.extensions
.iter()
.filter(|ext| group.holds(ext))
.count()
}
pub fn extensions_for(&self, plan: &SweepPlan) -> Result<Vec<&Extension>> {
let mut wanted: BTreeSet<usize> = BTreeSet::new();
for key in &plan.group_keys {
let group = self.group(key).ok_or_else(|| Error::GroupUnknown {
name: crate::lookup::scrub(key),
closest_groups: self.closest_group_keys(key),
})?;
for (position, ext) in self.extensions.iter().enumerate() {
if group.holds(ext) {
wanted.insert(position);
}
}
}
let mut named: BTreeSet<usize> = BTreeSet::new();
for suffix in &plan.extensions {
match self.by_suffix.get(suffix.as_str()) {
Some(position) => {
wanted.insert(*position);
named.insert(*position);
}
None => {
return Err(Error::ExtensionInvalid {
extension: suffix.as_str().to_owned(),
});
}
}
}
let mut relaxed = plan.filter.clone();
relaxed.registrable_only = false;
let mut chosen: Vec<&Extension> = wanted
.iter()
.filter_map(|position| {
self.extensions
.get(*position)
.map(|ext| (named.contains(position), ext))
})
.filter(|(was_named, ext)| {
if *was_named {
relaxed.admits(ext)
} else {
plan.filter.admits(ext)
}
})
.map(|(_, ext)| ext)
.collect();
if chosen.is_empty() {
if plan.filter.registrable_only {
let hidden = wanted
.iter()
.filter_map(|position| self.extensions.get(*position))
.filter(|ext| relaxed.admits(ext))
.count();
if hidden > 0 {
return Err(Error::CatalogRestrictedOnly { hidden });
}
}
return Err(Error::CatalogEmptySelection);
}
chosen.sort_by(|a, b| plan.sort.compare(a, b));
Ok(chosen)
}
#[must_use]
pub fn split_domain(&self, domain: &str) -> Option<(String, Suffix)> {
let domain = domain.trim().trim_end_matches('.').to_lowercase();
let labels: Vec<&str> = domain.split('.').collect();
if labels.len() < 2 {
return None;
}
for start in 1..labels.len() {
let candidate = labels.get(start..)?.join(".");
if self.by_suffix.contains_key(&candidate) {
let name = parse_name(labels.get(..start)?.join(".").as_str()).ok()?;
let suffix = Suffix::parse(&candidate).ok()?;
return Some((name, suffix));
}
}
let last = labels.last()?;
let name = parse_name(
labels
.get(..labels.len().checked_sub(1)?)?
.join(".")
.as_str(),
)
.ok()?;
Some((name, Suffix::parse(last).ok()?))
}
#[must_use]
pub fn closest_group_keys(&self, typed: &str) -> Vec<String> {
let typed = typed.to_lowercase();
let mut scored: Vec<(usize, &str)> = self
.groups
.iter()
.map(|group| (edit_distance(&typed, &group.key), group.key.as_str()))
.filter(|(distance, key)| *distance <= 3 || key.contains(&typed))
.collect();
scored.sort_by_key(|(distance, key)| (*distance, *key));
scored
.into_iter()
.take(3)
.map(|(_, key)| key.to_owned())
.collect()
}
#[must_use]
pub fn industry_keys(&self) -> Vec<String> {
let keys: BTreeSet<&str> = self
.extensions
.iter()
.flat_map(|ext| ext.industries.iter().map(String::as_str))
.collect();
keys.into_iter().map(str::to_owned).collect()
}
#[must_use]
pub fn region_keys(&self) -> Vec<String> {
let keys: BTreeSet<&str> = self
.extensions
.iter()
.filter_map(|ext| ext.region.as_deref())
.collect();
keys.into_iter().map(str::to_owned).collect()
}
}
fn edit_distance(left: &str, right: &str) -> usize {
let left: Vec<char> = left.chars().collect();
let right: Vec<char> = right.chars().collect();
if left.is_empty() {
return right.len();
}
if right.is_empty() {
return left.len();
}
let mut previous: Vec<usize> = (0..=right.len()).collect();
let mut current: Vec<usize> = vec![0; right.len() + 1];
for (i, left_char) in left.iter().enumerate() {
if let Some(slot) = current.first_mut() {
*slot = i + 1;
}
for (j, right_char) in right.iter().enumerate() {
let cost = usize::from(left_char != right_char);
let deletion = previous
.get(j + 1)
.copied()
.unwrap_or(usize::MAX)
.saturating_add(1);
let insertion = current
.get(j)
.copied()
.unwrap_or(usize::MAX)
.saturating_add(1);
let substitution = previous
.get(j)
.copied()
.unwrap_or(usize::MAX)
.saturating_add(cost);
if let Some(slot) = current.get_mut(j + 1) {
*slot = deletion.min(insertion).min(substitution);
}
}
std::mem::swap(&mut previous, &mut current);
}
previous.last().copied().unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tld::selection::{SortDirection, SortKey};
fn catalog() -> Catalog {
Catalog::bundled().expect("the bundled catalog must parse")
}
#[test]
fn the_bundled_catalog_loads_and_is_the_expected_schema() {
let catalog = catalog();
assert_eq!(catalog.version, CATALOG_VERSION);
assert!(!catalog.is_empty());
assert!(!catalog.groups.is_empty());
}
#[test]
fn every_family_has_at_least_one_group() {
let catalog = catalog();
for family in Family::all() {
assert!(
!catalog.groups_in(family).is_empty(),
"{family} has no groups"
);
}
}
#[test]
fn group_keys_are_unique() {
let catalog = catalog();
let unique: BTreeSet<&str> = catalog
.groups
.iter()
.map(|group| group.key.as_str())
.collect();
assert_eq!(unique.len(), catalog.groups.len());
}
#[test]
fn extension_suffixes_are_unique() {
let catalog = catalog();
let unique: BTreeSet<&str> = catalog
.extensions
.iter()
.map(|ext| ext.suffix.as_str())
.collect();
assert_eq!(unique.len(), catalog.extensions.len());
}
#[test]
fn every_group_holds_at_least_one_extension() {
let catalog = catalog();
for group in &catalog.groups {
assert!(
catalog.group_size(group) > 0,
"group `{}` is empty",
group.key
);
}
}
#[test]
fn resolving_a_group_returns_its_members_sorted() {
let catalog = catalog();
let plan = SweepPlan {
group_keys: vec!["tech".to_owned()],
sort: Sort::new(SortKey::Name, SortDirection::Ascending),
..SweepPlan::default()
};
let chosen = catalog.extensions_for(&plan).unwrap();
assert!(!chosen.is_empty());
let names: Vec<&str> = chosen.iter().map(|ext| ext.suffix.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted);
}
#[test]
fn groups_and_named_extensions_union_without_duplicates() {
let catalog = catalog();
let plan = SweepPlan {
group_keys: vec!["top-10".to_owned()],
extensions: vec![Suffix::parse("com").unwrap()],
..SweepPlan::default()
};
let chosen = catalog.extensions_for(&plan).unwrap();
let com_count = chosen
.iter()
.filter(|ext| ext.suffix.as_str() == "com")
.count();
assert_eq!(com_count, 1);
}
#[test]
fn an_unknown_group_names_the_closest_match() {
let catalog = catalog();
let plan = SweepPlan {
group_keys: vec!["tec".to_owned()],
..SweepPlan::default()
};
let error = catalog.extensions_for(&plan).unwrap_err();
match error {
Error::GroupUnknown { closest_groups, .. } => {
assert!(
closest_groups.contains(&"tech".to_owned()),
"got {closest_groups:?}"
);
}
other => panic!("expected GroupUnknown, got {other:?}"),
}
}
#[test]
fn a_full_domain_splits_at_its_longest_known_extension() {
let catalog = catalog();
assert_eq!(
catalog
.split_domain("shop.co.uk")
.map(|(name, suffix)| (name, suffix.to_string())),
Some(("shop".to_owned(), "co.uk".to_owned()))
);
assert_eq!(
catalog
.split_domain("apple.com")
.map(|(name, suffix)| (name, suffix.to_string())),
Some(("apple".to_owned(), "com".to_owned()))
);
assert_eq!(
catalog
.split_domain("example.com.bd")
.map(|(name, suffix)| (name, suffix.to_string())),
Some(("example".to_owned(), "com.bd".to_owned()))
);
}
#[test]
fn a_domain_under_an_unknown_extension_still_splits() {
let catalog = catalog();
assert_eq!(
catalog
.split_domain("thing.zzznotreal")
.map(|(name, suffix)| (name, suffix.to_string())),
Some(("thing".to_owned(), "zzznotreal".to_owned()))
);
}
#[test]
fn a_bare_name_with_no_dot_does_not_split() {
assert!(catalog().split_domain("example").is_none());
assert!(catalog().split_domain("").is_none());
}
#[test]
fn a_trailing_dot_and_case_do_not_change_the_split() {
let catalog = catalog();
assert_eq!(
catalog
.split_domain("APPLE.COM.")
.map(|(name, suffix)| (name, suffix.to_string())),
Some(("apple".to_owned(), "com".to_owned()))
);
}
#[test]
fn a_catalog_carrying_an_unusable_extension_is_refused_rather_than_loaded() {
let usable = r#"{"version":1,"generated_on":"2026-08-19",
"extensions":[{"suffix":"com.bd","kind":"country"}],"groups":[]}"#;
assert!(Catalog::from_json(usable).is_ok());
let unusable = r#"{"version":1,"generated_on":"2026-08-19",
"extensions":[{"suffix":"-not-a-suffix-","kind":"country"}],"groups":[]}"#;
let error = Catalog::from_json(unusable)
.expect_err("a suffix the parser refuses must not enter through the catalog");
assert_eq!(error.id(), crate::error::ErrorId::CatalogMalformed);
}
#[test]
fn edit_distance_is_symmetric_and_zero_on_a_match() {
assert_eq!(edit_distance("tech", "tech"), 0);
assert_eq!(edit_distance("tech", "tec"), 1);
assert_eq!(edit_distance("tec", "tech"), 1);
assert_eq!(edit_distance("", "abc"), 3);
assert_eq!(edit_distance("abc", ""), 3);
}
#[test]
fn a_match_hidden_only_because_it_is_restricted_says_so_and_names_the_flag() {
let catalog = Catalog::bundled().expect("the bundled catalog parses");
let plan = SweepPlan {
group_keys: vec!["everything".to_owned()],
filter: Filter {
search: Some("bank".to_owned()),
..Filter::registrable()
},
..SweepPlan::default()
};
let error = catalog
.extensions_for(&plan)
.expect_err("every match is a zone the public cannot register under");
assert!(matches!(error, Error::CatalogRestrictedOnly { hidden } if hidden > 0));
assert!(error.remedy().contains("--include-restricted"));
assert_eq!(error.id().as_str(), "catalog.restricted_only");
}
#[test]
fn naming_a_restricted_zone_outright_checks_it_instead_of_hiding_it() {
let catalog = Catalog::bundled().expect("the bundled catalog parses");
let restricted = catalog
.extensions
.iter()
.find(|ext| !ext.registrable)
.map(|ext| ext.suffix.clone())
.expect("the catalog carries at least one restricted zone");
let plan = SweepPlan {
extensions: vec![restricted.clone()],
filter: Filter::registrable(),
..SweepPlan::default()
};
let chosen = catalog
.extensions_for(&plan)
.expect("a zone named outright is checked, restricted or not");
assert_eq!(chosen.len(), 1);
assert_eq!(chosen[0].suffix, restricted);
}
}