use std::collections::HashSet;
pub fn normalize_domain(domain: &str) -> Option<String> {
let trimmed = domain.trim().trim_end_matches('.');
if trimmed.is_empty() {
return None;
}
match url::Host::parse(trimmed) {
Ok(url::Host::Domain(d)) => Some(d.to_lowercase()),
_ => Some(trimmed.to_lowercase()),
}
}
pub fn dedup_domains<I: IntoIterator<Item = String>>(domains: I) -> HashSet<String> {
let mut seen = HashSet::new();
for d in domains {
if let Some(n) = normalize_domain(&d) {
seen.insert(n);
}
}
seen
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn normalize_lowercase() {
assert_eq!(
normalize_domain("API.Example.COM"),
Some("api.example.com".to_string())
);
}
#[test]
fn normalize_trailing_dot() {
assert_eq!(
normalize_domain("api.example.com."),
Some("api.example.com".to_string())
);
}
#[test]
fn normalize_punycode() {
assert_eq!(
normalize_domain("münchen.example.com"),
Some("xn--mnchen-3ya.example.com".to_string())
);
}
#[test]
fn dedup_is_commutative() {
let a = vec!["API.Example.COM".into(), "api.example.com.".into()];
let b = vec!["api.example.com.".into(), "API.Example.COM".into()];
assert_eq!(dedup_domains(a), dedup_domains(b));
}
#[test]
fn dedup_mixed_unicode_and_ace() {
let domains = vec![
"münchen.example.com".into(),
"xn--mnchen-3ya.example.com".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("xn--mnchen-3ya.example.com"));
}
#[test]
fn dedup_exact_duplicates() {
let domains = vec![
"api.example.com".into(),
"api.example.com".into(),
"api.example.com".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("api.example.com"));
}
#[test]
fn dedup_case_variations() {
let domains = vec![
"API.Example.COM".into(),
"api.example.com".into(),
"Api.Example.Com".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("api.example.com"));
}
#[test]
fn dedup_trailing_dots() {
let domains = vec![
"api.example.com.".into(),
"api.example.com".into(),
"api.example.com.".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("api.example.com"));
}
#[test]
fn dedup_subdomain_vs_root_domain() {
let domains = vec![
"example.com".into(),
"api.example.com".into(),
"www.example.com".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 3);
assert!(deduped.contains("example.com"));
assert!(deduped.contains("api.example.com"));
assert!(deduped.contains("www.example.com"));
}
#[test]
fn normalize_empty_string() {
assert_eq!(normalize_domain(""), None);
}
#[test]
fn normalize_whitespace_only() {
assert_eq!(normalize_domain(" "), None);
}
#[test]
fn normalize_whitespace_surrounded() {
assert_eq!(
normalize_domain(" api.example.com "),
Some("api.example.com".to_string())
);
}
#[test]
fn normalize_ipv4_returns_lowercase() {
assert_eq!(
normalize_domain("1.2.3.4"),
Some("1.2.3.4".to_string())
);
}
#[test]
fn normalize_unicode_idn() {
assert_eq!(
normalize_domain("例え.jp"),
Some("xn--r8jz45g.jp".to_string())
);
}
#[test]
fn dedup_empty_strings_dropped() {
let domains = vec![
"".to_string(),
" ".to_string(),
"api.example.com".to_string(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("api.example.com"));
}
#[test]
fn dedup_multiple_punycode_variants() {
let domains = vec![
"münchen.example.com".into(),
"MÜNCHEN.EXAMPLE.COM.".into(),
"xn--mnchen-3ya.example.com".into(),
"xn--mnchen-3ya.example.com.".into(),
];
let deduped = dedup_domains(domains);
assert_eq!(deduped.len(), 1);
assert!(deduped.contains("xn--mnchen-3ya.example.com"));
}
proptest! {
#[test]
fn normalize_never_panics(domain in ".*") {
let _ = normalize_domain(&domain);
}
#[test]
fn normalize_is_lowercase(domain in ".*") {
if let Some(n) = normalize_domain(&domain) {
prop_assert_eq!(n.clone(), n.to_lowercase());
}
}
#[test]
fn dedup_never_panics(domains in prop::collection::vec(".*", 0..50)) {
let _ = dedup_domains(domains);
}
#[test]
fn dedup_never_grows(domains in prop::collection::vec(".*", 0..50)) {
let input_len = domains.len();
let out = dedup_domains(domains);
prop_assert!(out.len() <= input_len);
}
#[test]
fn normalize_idempotent(domain in "[a-zA-Z0-9.-]{0,128}") {
let once = normalize_domain(&domain);
let twice = once.as_deref().and_then(normalize_domain);
prop_assert_eq!(once, twice);
}
}
}