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 find_covering(&self, suffix: &Suffix) -> Option<&Extension> {
suffix.ancestors().iter().find_map(|candidate| {
self.by_suffix
.get(candidate)
.and_then(|position| self.extensions.get(*position))
})
}
#[must_use]
pub fn group(&self, id: &str) -> Option<&Group> {
self.groups.iter().find(|group| group.key == id)
}
#[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: key.clone(),
closest_groups: self.closest_group_keys(key),
})?;
for (position, ext) in self.extensions.iter().enumerate() {
if group.holds(ext) {
wanted.insert(position);
}
}
}
for suffix in &plan.extensions {
match self.by_suffix.get(suffix.as_str()) {
Some(position) => {
wanted.insert(*position);
}
None => {
return Err(Error::ExtensionInvalid {
extension: suffix.as_str().to_owned(),
});
}
}
}
let mut chosen: Vec<&Extension> = wanted
.into_iter()
.filter_map(|position| self.extensions.get(position))
.filter(|ext| plan.filter.admits(ext))
.collect();
if chosen.is_empty() {
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, id)| *distance <= 3 || id.contains(&typed))
.collect();
scored.sort_by_key(|(distance, id)| (*distance, *id));
scored
.into_iter()
.take(3)
.map(|(_, id)| id.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_ids_are_unique() {
let catalog = catalog();
let unique: BTreeSet<&str> = catalog.groups.iter().map(|g| g.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(|e| e.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(|e| e.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(|e| e.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 err = catalog.extensions_for(&plan).unwrap_err();
match err {
Error::GroupUnknown { closest_groups, .. } => {
assert!(
closest_groups.contains(&"tech".to_owned()),
"got {closest_groups:?}"
);
}
other => panic!("expected GroupUnknown, got {other:?}"),
}
}
#[test]
fn find_closest_walks_up_the_parent_chain() {
let catalog = catalog();
let deep = Suffix::parse("shop.co.uk").unwrap();
let found = catalog.find_covering(&deep);
assert!(found.is_some());
}
#[test]
fn a_full_domain_splits_at_its_longest_known_extension() {
let catalog = catalog();
assert_eq!(
catalog
.split_domain("shop.co.uk")
.map(|(n, s)| (n, s.to_string())),
Some(("shop".to_owned(), "co.uk".to_owned()))
);
assert_eq!(
catalog
.split_domain("apple.com")
.map(|(n, s)| (n, s.to_string())),
Some(("apple".to_owned(), "com".to_owned()))
);
assert_eq!(
catalog
.split_domain("example.com.bd")
.map(|(n, s)| (n, s.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(|(n, s)| (n, s.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(|(n, s)| (n, s.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);
}
}