use std::borrow::Borrow;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr;
use crate::domain::idn;
use crate::error::{DomainError, Error, Result};
#[derive(Debug, Clone)]
pub struct Tld {
ascii: String,
unicode: String,
}
impl Tld {
pub fn parse(input: &str) -> Result<Self> {
let trimmed = input.trim().trim_matches('.');
if trimmed.is_empty() {
return Err(Error::InvalidDomain {
input: input.to_string(),
reason: DomainError::Empty,
});
}
let (ascii, unicode) = idn::both_forms(trimmed).map_err(|reason| Error::InvalidDomain {
input: input.to_string(),
reason,
})?;
for label in ascii.split('.') {
idn::validate_label(label).map_err(|reason| Error::InvalidDomain {
input: input.to_string(),
reason,
})?;
}
Ok(Tld { ascii, unicode })
}
pub fn ascii(&self) -> &str {
&self.ascii
}
pub fn unicode(&self) -> &str {
&self.unicode
}
pub fn with_dot(&self) -> String {
format!(".{}", self.ascii)
}
pub fn label_count(&self) -> usize {
self.ascii.split('.').count()
}
pub fn is_idn(&self) -> bool {
self.ascii != self.unicode
}
pub fn root_label(&self) -> &str {
self.ascii.rsplit('.').next().unwrap_or(&self.ascii)
}
pub(crate) fn from_ascii_unchecked(ascii: impl Into<String>) -> Self {
let ascii = ascii.into();
let unicode = idn::to_unicode_lossy(&ascii);
Tld { ascii, unicode }
}
}
impl PartialEq for Tld {
fn eq(&self, other: &Self) -> bool {
self.ascii == other.ascii
}
}
impl Eq for Tld {}
impl PartialOrd for Tld {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Tld {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.label_count()
.cmp(&other.label_count())
.then_with(|| self.ascii.cmp(&other.ascii))
}
}
impl Hash for Tld {
fn hash<H: Hasher>(&self, state: &mut H) {
self.ascii.hash(state);
}
}
impl Borrow<str> for Tld {
fn borrow(&self) -> &str {
&self.ascii
}
}
impl AsRef<str> for Tld {
fn as_ref(&self) -> &str {
&self.ascii
}
}
impl fmt::Display for Tld {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.ascii)
}
}
impl FromStr for Tld {
type Err = Error;
fn from_str(s: &str) -> Result<Self> {
Tld::parse(s)
}
}
impl TryFrom<&str> for Tld {
type Error = Error;
fn try_from(value: &str) -> Result<Self> {
Tld::parse(value)
}
}
impl TryFrom<String> for Tld {
type Error = Error;
fn try_from(value: String) -> Result<Self> {
Tld::parse(&value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drops_leading_dot_and_lowercases() {
assert_eq!(Tld::parse(".COM").unwrap().ascii(), "com");
assert_eq!(Tld::parse(" Co.Uk ").unwrap().ascii(), "co.uk");
}
#[test]
fn keeps_both_idn_forms() {
let tld = Tld::parse("xn--p1ai").unwrap();
assert_eq!(tld.ascii(), "xn--p1ai");
assert_eq!(tld.unicode(), "рф");
assert!(tld.is_idn());
assert_eq!(Tld::parse("рф").unwrap(), tld);
}
#[test]
fn equality_ignores_spelling() {
assert_eq!(Tld::parse("рф").unwrap(), Tld::parse("XN--P1AI").unwrap());
}
#[test]
fn root_label_is_the_true_top_level() {
assert_eq!(Tld::parse("co.uk").unwrap().root_label(), "uk");
assert_eq!(Tld::parse("com").unwrap().root_label(), "com");
}
#[test]
fn ordering_puts_shorter_suffixes_first() {
let mut tlds = [
Tld::parse("co.uk").unwrap(),
Tld::parse("uk").unwrap(),
Tld::parse("com").unwrap(),
];
tlds.sort();
let rendered: Vec<_> = tlds.iter().map(Tld::ascii).collect();
assert_eq!(rendered, ["com", "uk", "co.uk"]);
}
#[test]
fn rejects_empty_and_illegal() {
assert!(Tld::parse("").is_err());
assert!(Tld::parse(".").is_err());
assert!(Tld::parse("-com").is_err());
assert!(Tld::parse("a..b").is_err());
}
}