monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! The [`Tld`] value object.

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};

/// A registry suffix, normalised and stored in both of its forms.
///
/// "TLD" is used in the loose sense the WHOIS world uses it: the part of a name
/// that decides which registry to ask. That is often a single label (`com`) but
/// just as often several (`co.uk`, `com.au`), so this type does not assume one.
///
/// The value is always lower case and never carries a leading dot. Both the
/// punycode and the Unicode form are kept, because registry data is keyed by the
/// ASCII form while humans read the other one:
///
/// ```
/// use monovm_whois::Tld;
///
/// let tld: Tld = "рф".parse().unwrap();
/// assert_eq!(tld.ascii(), "xn--p1ai");
/// assert_eq!(tld.unicode(), "рф");
///
/// // A leading dot is accepted and dropped, and case is normalised.
/// assert_eq!(Tld::parse(".CO.UK").unwrap().ascii(), "co.uk");
/// ```
///
/// Equality, ordering and hashing are all defined on the ASCII form, so the two
/// spellings of one suffix are the same key.
#[derive(Debug, Clone)]
pub struct Tld {
    ascii: String,
    unicode: String,
}

impl Tld {
    /// Normalise and validate a suffix.
    ///
    /// A leading dot is accepted and dropped, so both `com` and `.com` work.
    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 })
    }

    /// The punycode form, without a leading dot. This is the registry data key.
    pub fn ascii(&self) -> &str {
        &self.ascii
    }

    /// The Unicode form, without a leading dot.
    pub fn unicode(&self) -> &str {
        &self.unicode
    }

    /// The punycode form with a leading dot, as WHOIS documentation writes it.
    pub fn with_dot(&self) -> String {
        format!(".{}", self.ascii)
    }

    /// How many labels the suffix has: 1 for `com`, 2 for `co.uk`.
    pub fn label_count(&self) -> usize {
        self.ascii.split('.').count()
    }

    /// Whether the suffix is internationalised, i.e. its two forms differ.
    pub fn is_idn(&self) -> bool {
        self.ascii != self.unicode
    }

    /// The last label: `uk` for `co.uk`, `com` for `com`.
    ///
    /// Useful for conventions keyed on the true top level, such as the
    /// `whois.nic.<tld>` naming rule for gTLDs.
    pub fn root_label(&self) -> &str {
        self.ascii.rsplit('.').next().unwrap_or(&self.ascii)
    }

    /// Build without normalising. Callers must pass an already-normalised,
    /// lower-case ASCII suffix; used by the registry loader on data it has just
    /// validated, to avoid re-running IDNA over thousands of entries.
    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 {
        // Shorter suffixes first, then alphabetical, so a sorted list reads
        // `com`, `uk`, `co.uk` rather than interleaving levels.
        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 {
    /// Writes the ASCII form without a leading dot.
    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());
    }
}