monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
//! Reading the dates registries write.
//!
//! There is no standard here. RFC 5731 asks for RFC 3339 and most gTLD registries
//! comply, but the ccTLDs predate it and each chose its own: Nominet writes
//! `14-Mar-2027`, TCI writes `2027.03.14`, JPRS writes `2027/03/14`, DENIC writes
//! `2027-03-14T00:00:00+01:00`, and a few still emit a bare `20270314`.
//!
//! Anything that cannot be read confidently is reported as absent. A date guessed
//! wrong is worse than a date missing, and `03/14/2027` versus `14/03/2027` is not
//! a guess anyone should make silently.

use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc};

/// Formats with both a date and a time, tried in order.
const DATETIME_FORMATS: &[&str] = &[
    "%Y-%m-%d %H:%M:%S",
    "%Y-%m-%dT%H:%M:%S",
    "%Y-%m-%d %H:%M",
    "%Y/%m/%d %H:%M:%S",
    "%Y.%m.%d %H:%M:%S",
    "%d-%b-%Y %H:%M:%S",
    "%d.%m.%Y %H:%M:%S",
    "%d/%m/%Y %H:%M:%S",
    "%b %d %H:%M:%S %Y",
    "%a %b %d %H:%M:%S %Y",
    "%Y%m%d%H%M%S",
];

/// Date-only formats, tried in order.
///
/// Deliberately excludes any all-numeric day-first or month-first format:
/// `03/14/2027` and `14/03/2027` are indistinguishable for the first twelve days
/// of a month, so a lone `%d/%m/%Y` here would silently produce wrong dates.
const DATE_FORMATS: &[&str] = &[
    "%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%d-%b-%Y", "%d %b %Y", "%b %d %Y", "%d-%B-%Y", "%Y%m%d",
];

/// Suffixes registries append that carry no information once normalised.
const NOISE: &[&str] = &[
    " utc", " gmt", " (utc)", " (gmt)", " z", " est", " edt", " cst", " cet", " cest", " jst",
    " clst", " clt", " utc+0", " +0000", " -0000",
];

/// Parse a date or timestamp into UTC.
///
/// A value with no time is taken as midnight, and a value with no zone as UTC.
/// Both assumptions are visible in the result rather than hidden: a caller that
/// needs sub-day precision should check the source registry rather than trust a
/// synthesised midnight.
///
/// ```
/// use monovm_whois::parser::parse_datetime;
///
/// // The RFC 3339 form most gTLD registries use.
/// let parsed = parse_datetime("2027-03-14T09:30:00Z").unwrap();
/// assert_eq!(parsed.to_rfc3339(), "2027-03-14T09:30:00+00:00");
///
/// // Nominet's form, and TCI's, and a bare date.
/// for value in ["14-Mar-2027", "2027.03.14", "2027-03-14", "20270314"] {
///     assert_eq!(
///         parse_datetime(value).unwrap().format("%Y-%m-%d").to_string(),
///         "2027-03-14",
///         "for {value}"
///     );
/// }
///
/// // Ambiguous or unreadable values are reported as absent, never guessed.
/// assert!(parse_datetime("03/14/2027").is_none());
/// assert!(parse_datetime("not a date").is_none());
/// ```
pub fn parse_datetime(value: &str) -> Option<DateTime<Utc>> {
    let cleaned = clean(value);
    if cleaned.is_empty() {
        return None;
    }

    // Offset-aware formats first, so a stated zone is honoured rather than
    // overwritten by the UTC assumption.
    if let Ok(parsed) = DateTime::parse_from_rfc3339(&cleaned) {
        return Some(parsed.with_timezone(&Utc));
    }
    if let Ok(parsed) = DateTime::parse_from_rfc2822(&cleaned) {
        return Some(parsed.with_timezone(&Utc));
    }
    for format in [
        "%Y-%m-%d %H:%M:%S%:z",
        "%Y-%m-%dT%H:%M:%S%:z",
        "%Y-%m-%d %H:%M:%S %z",
    ] {
        if let Ok(parsed) = DateTime::parse_from_str(&cleaned, format) {
            return Some(parsed.with_timezone(&Utc));
        }
    }

    for format in DATETIME_FORMATS {
        if let Ok(naive) = NaiveDateTime::parse_from_str(&cleaned, format) {
            return Some(Utc.from_utc_datetime(&naive));
        }
    }
    for format in DATE_FORMATS {
        if let Ok(date) = NaiveDate::parse_from_str(&cleaned, format) {
            return Some(Utc.from_utc_datetime(&date.and_hms_opt(0, 0, 0)?));
        }
    }

    None
}

/// Strip the decoration registries put around dates.
fn clean(value: &str) -> String {
    let mut text = value.trim().to_string();

    // Some registries annotate the value: `2027-03-14 (registry expiry)`.
    if let Some(index) = text.find(" (") {
        // Keep a trailing `(UTC)`, which the noise pass handles, but drop prose.
        let tail = text[index..].to_ascii_lowercase();
        if !tail.starts_with(" (utc)") && !tail.starts_with(" (gmt)") {
            text.truncate(index);
        }
    }

    // Fractional seconds beyond what the formats above describe.
    if let Some(dot) = text.find('.') {
        let after = &text[dot + 1..];
        let digits = after.chars().take_while(char::is_ascii_digit).count();
        // A dot in `2027.03.14` is a separator, not a fraction; only strip when it
        // follows a time.
        if digits > 0 && text[..dot].contains(':') {
            let end = dot + 1 + digits;
            text.replace_range(dot..end, "");
        }
    }

    let lowered = text.to_ascii_lowercase();
    for suffix in NOISE {
        if lowered.ends_with(suffix) {
            text.truncate(text.len() - suffix.len());
            break;
        }
    }

    text.trim().trim_end_matches(',').trim().to_string()
}

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

    fn ymd(value: &str) -> Option<String> {
        parse_datetime(value).map(|parsed| parsed.format("%Y-%m-%d").to_string())
    }

    fn full(value: &str) -> Option<String> {
        parse_datetime(value).map(|parsed| parsed.format("%Y-%m-%d %H:%M:%S").to_string())
    }

    #[test]
    fn reads_the_rfc3339_form_gtlds_use() {
        assert_eq!(full("2027-03-14T09:30:00Z").unwrap(), "2027-03-14 09:30:00");
        assert_eq!(
            full("2027-03-14T09:30:00.123Z").unwrap(),
            "2027-03-14 09:30:00"
        );
    }

    #[test]
    fn honours_a_stated_offset() {
        // 09:30 at +02:00 is 07:30 UTC; assuming UTC would be two hours wrong.
        assert_eq!(
            full("2027-03-14T09:30:00+02:00").unwrap(),
            "2027-03-14 07:30:00"
        );
        assert_eq!(
            full("2027-03-14 09:30:00+0200").unwrap(),
            "2027-03-14 07:30:00"
        );
    }

    #[test]
    fn reads_the_cctld_dialects() {
        let cases = [
            ("14-Mar-2027", "2027-03-14"),
            ("14 Mar 2027", "2027-03-14"),
            ("2027.03.14", "2027-03-14"),
            ("2027/03/14", "2027-03-14"),
            ("20270314", "2027-03-14"),
            ("2027-03-14 09:30:00", "2027-03-14"),
            ("14.03.2027 09:30:00", "2027-03-14"),
            ("2027-03-14T09:30:00", "2027-03-14"),
        ];

        for (value, expected) in cases {
            assert_eq!(ymd(value).as_deref(), Some(expected), "for {value:?}");
        }
    }

    #[test]
    fn a_bare_date_becomes_midnight() {
        assert_eq!(full("2027-03-14").unwrap(), "2027-03-14 00:00:00");
    }

    #[test]
    fn strips_the_zone_words_registries_append() {
        for value in [
            "2027-03-14 09:30:00 UTC",
            "2027-03-14 09:30:00 GMT",
            "2027-03-14 09:30:00 (UTC)",
        ] {
            assert_eq!(
                full(value).as_deref(),
                Some("2027-03-14 09:30:00"),
                "for {value:?}"
            );
        }
    }

    #[test]
    fn strips_trailing_prose() {
        assert_eq!(
            ymd("2027-03-14 (registry expiry date)").as_deref(),
            Some("2027-03-14")
        );
    }

    #[test]
    fn refuses_ambiguous_numeric_dates() {
        // Both readings are plausible and one of them is wrong, so neither is used.
        assert!(parse_datetime("03/14/2027").is_none());
        assert!(parse_datetime("14/03/2027").is_none());
        assert!(parse_datetime("03-14-2027").is_none());
    }

    #[test]
    fn refuses_what_it_cannot_read() {
        for value in ["", "   ", "not a date", "n/a", "0000-00-00", "unknown"] {
            assert!(parse_datetime(value).is_none(), "accepted {value:?}");
        }
    }

    #[test]
    fn a_dot_separator_is_not_a_fraction() {
        // `2027.03.14` must not lose `.03`.
        assert_eq!(ymd("2027.03.14").as_deref(), Some("2027-03-14"));
        assert_eq!(ymd("2027.03.14 09:30:00").as_deref(), Some("2027-03-14"));
    }
}