whoizz 0.1.0

Tiny, dependency-light RFC 3912 whois client with best-effort field extraction
Documentation
/// Best-effort extraction of common fields from a whois response body.
///
/// Whois responses are not standardized across registries. This module
/// looks for the most common field-name patterns and is tolerant of
/// missing / differently-named fields.
#[derive(Debug, Default, Clone)]
pub(crate) struct Fields {
    pub registrar: Option<String>,
    pub created: Option<String>,
    pub expires: Option<String>,
    pub nameservers: Vec<String>,
}

pub(crate) fn extract(body: &str) -> Fields {
    let mut f = Fields::default();

    for line in body.lines() {
        // Skip comments and blank lines.
        let line = line.trim();
        if line.is_empty() || line.starts_with('%') || line.starts_with('#') {
            continue;
        }

        let Some((key, value)) = line.split_once(':') else {
            continue;
        };
        let key = key.trim().to_ascii_lowercase();
        let value = value.trim();
        if value.is_empty() {
            continue;
        }

        match key.as_str() {
            "registrar" | "sponsoring registrar" | "registrar name" => {
                if f.registrar.is_none() {
                    f.registrar = Some(value.to_string());
                }
            }
            "creation date"
            | "created"
            | "created on"
            | "created date"
            | "registered"
            | "registered on"
            | "registration time"
            | "domain registration date" => {
                if f.created.is_none() {
                    f.created = Some(value.to_string());
                }
            }
            "registry expiry date"
            | "registrar registration expiration date"
            | "expiry date"
            | "expiration date"
            | "expiration time"
            | "expires"
            | "expires on"
            | "paid-till" => {
                if f.expires.is_none() {
                    f.expires = Some(value.to_string());
                }
            }
            "name server" | "nameserver" | "nserver" | "name servers" => {
                // Take the first whitespace-separated token (some responses
                // append the NS IP after the hostname). Normalize to lower
                // case and strip trailing dots.
                let ns = value
                    .split_whitespace()
                    .next()
                    .unwrap_or(value)
                    .trim_end_matches('.')
                    .to_ascii_lowercase();
                if !ns.is_empty() && !f.nameservers.contains(&ns) {
                    f.nameservers.push(ns);
                }
            }
            _ => {}
        }
    }

    f
}

#[cfg(test)]
mod tests {
    use super::extract;

    const SAMPLE: &str = "\
% sample response
Domain Name: RUST-LANG.ORG
Registry Domain ID: D402200000000000000-LROR
Registrar: MarkMonitor Inc.
Creation Date: 2010-04-08T15:24:12Z
Registry Expiry Date: 2028-04-08T15:24:12Z
Name Server: NS-1000.AWSDNS-00.CO.UK.
Name Server: NS-2000.AWSDNS-00.NET.
";

    #[test]
    fn extracts_common_fields() {
        let f = extract(SAMPLE);
        assert_eq!(f.registrar.as_deref(), Some("MarkMonitor Inc."));
        assert_eq!(f.created.as_deref(), Some("2010-04-08T15:24:12Z"));
        assert_eq!(f.expires.as_deref(), Some("2028-04-08T15:24:12Z"));
        assert_eq!(
            f.nameservers,
            vec![
                "ns-1000.awsdns-00.co.uk".to_string(),
                "ns-2000.awsdns-00.net".to_string()
            ]
        );
    }

    #[test]
    fn ignores_comments_and_blank_lines() {
        let body = "% comment\n\n# another\nRegistrar: Test\n";
        assert_eq!(extract(body).registrar.as_deref(), Some("Test"));
    }
}