use std::collections::BTreeMap;
use std::fmt;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::domain::DomainName;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WhoisRecord {
pub domain: Option<DomainName>,
pub registry_id: Option<String>,
pub registrar: Option<String>,
pub registrar_iana_id: Option<String>,
pub registrar_whois_server: Option<String>,
pub registrar_url: Option<String>,
pub abuse_contact_email: Option<String>,
pub abuse_contact_phone: Option<String>,
pub created: Option<DateTime<Utc>>,
pub updated: Option<DateTime<Utc>>,
pub expires: Option<DateTime<Utc>>,
pub statuses: Vec<String>,
pub name_servers: Vec<String>,
pub dnssec: Option<bool>,
pub registrant: Option<Contact>,
pub admin: Option<Contact>,
pub tech: Option<Contact>,
pub billing: Option<Contact>,
pub extra: BTreeMap<String, Vec<String>>,
}
impl WhoisRecord {
pub fn new() -> Self {
WhoisRecord::default()
}
pub fn is_empty(&self) -> bool {
self.domain.is_none()
&& self.registrar.is_none()
&& self.created.is_none()
&& self.expires.is_none()
&& self.statuses.is_empty()
&& self.name_servers.is_empty()
&& self.registrant.is_none()
&& self.extra.is_empty()
}
pub fn days_until_expiry(&self) -> Option<i64> {
self.expires
.map(|expires| (expires - Utc::now()).num_days())
}
pub fn is_expired(&self) -> bool {
self.days_until_expiry().is_some_and(|days| days < 0)
}
pub fn is_transfer_locked(&self) -> bool {
self.statuses.iter().any(|status| {
let status = status.replace([' ', '-', '_'], "");
status.contains("transferprohibited") || status.contains("locked")
})
}
pub fn is_pending_delete(&self) -> bool {
self.statuses.iter().any(|status| {
let status = status.replace([' ', '-', '_'], "");
status.contains("pendingdelete") || status.contains("redemptionperiod")
})
}
pub fn extra_field(&self, key: &str) -> Option<&str> {
self.extra
.get(&key.to_lowercase())
.and_then(|values| values.first())
.map(String::as_str)
}
}
impl fmt::Display for WhoisRecord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut lines: Vec<String> = Vec::new();
if let Some(domain) = &self.domain {
lines.push(format!("domain: {}", domain.as_ascii()));
}
if let Some(registrar) = &self.registrar {
lines.push(format!("registrar: {registrar}"));
}
for (label, value) in [
("created", self.created),
("updated", self.updated),
("expires", self.expires),
] {
if let Some(when) = value {
lines.push(format!("{label}: {}", when.format("%Y-%m-%d")));
}
}
if !self.statuses.is_empty() {
lines.push(format!("status: {}", self.statuses.join(", ")));
}
if !self.name_servers.is_empty() {
lines.push(format!("nameservers: {}", self.name_servers.join(", ")));
}
f.write_str(&lines.join("\n"))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Contact {
pub handle: Option<String>,
pub name: Option<String>,
pub organization: Option<String>,
pub street: Vec<String>,
pub city: Option<String>,
pub state: Option<String>,
pub postal_code: Option<String>,
pub country: Option<String>,
pub phone: Option<String>,
pub fax: Option<String>,
pub email: Option<String>,
}
impl Contact {
pub fn is_empty(&self) -> bool {
self.handle.is_none()
&& self.name.is_none()
&& self.organization.is_none()
&& self.street.is_empty()
&& self.city.is_none()
&& self.state.is_none()
&& self.postal_code.is_none()
&& self.country.is_none()
&& self.phone.is_none()
&& self.fax.is_none()
&& self.email.is_none()
}
pub fn is_redacted(&self) -> bool {
const MARKERS: [&str; 8] = [
"redacted",
"not disclosed",
"data protected",
"privacy",
"gdpr",
"withheld",
"statutory masking",
"non-public data",
];
[
self.name.as_deref(),
self.organization.as_deref(),
self.email.as_deref(),
]
.into_iter()
.flatten()
.any(|value| {
let value = value.to_lowercase();
MARKERS.iter().any(|marker| value.contains(marker))
})
}
pub fn display_name(&self) -> Option<&str> {
self.name
.as_deref()
.or(self.organization.as_deref())
.or(self.handle.as_deref())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn at(year: i32, month: u32, day: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(year, month, day, 0, 0, 0).unwrap()
}
#[test]
fn a_new_record_is_empty() {
assert!(WhoisRecord::new().is_empty());
}
#[test]
fn one_populated_field_makes_it_non_empty() {
let record = WhoisRecord {
registrar: Some("Example LLC".into()),
..WhoisRecord::default()
};
assert!(!record.is_empty());
}
#[test]
fn expiry_is_measured_in_days_either_side_of_now() {
let future = WhoisRecord {
expires: Some(Utc::now() + chrono::Duration::days(30)),
..WhoisRecord::default()
};
assert!(!future.is_expired());
assert!((29..=30).contains(&future.days_until_expiry().unwrap()));
let past = WhoisRecord {
expires: Some(Utc::now() - chrono::Duration::days(5)),
..WhoisRecord::default()
};
assert!(past.is_expired());
assert!(past.days_until_expiry().unwrap() < 0);
assert!(!WhoisRecord::new().is_expired());
assert!(WhoisRecord::new().days_until_expiry().is_none());
}
#[test]
fn transfer_locks_are_recognised_in_every_spelling() {
for status in [
"clientTransferProhibited",
"client transfer prohibited",
"CLIENT-TRANSFER-PROHIBITED",
"serverTransferProhibited",
"REGISTRAR-LOCKED",
] {
let record = WhoisRecord {
statuses: vec![status.to_lowercase()],
..WhoisRecord::default()
};
assert!(record.is_transfer_locked(), "missed {status:?}");
}
let ok = WhoisRecord {
statuses: vec!["ok".into()],
..WhoisRecord::default()
};
assert!(!ok.is_transfer_locked());
}
#[test]
fn deletion_states_are_recognised() {
for status in ["pendingDelete", "redemptionPeriod", "pending delete"] {
let record = WhoisRecord {
statuses: vec![status.to_lowercase()],
..WhoisRecord::default()
};
assert!(record.is_pending_delete(), "missed {status:?}");
}
}
#[test]
fn unrecognised_fields_are_reachable() {
let mut extra = BTreeMap::new();
extra.insert("eligibility type".to_string(), vec!["Company".to_string()]);
let record = WhoisRecord {
extra,
..WhoisRecord::default()
};
assert_eq!(record.extra_field("Eligibility Type"), Some("Company"));
assert_eq!(record.extra_field("absent"), None);
assert!(!record.is_empty());
}
#[test]
fn redaction_is_told_apart_from_absence() {
let redacted = Contact {
name: Some("REDACTED FOR PRIVACY".into()),
..Contact::default()
};
assert!(redacted.is_redacted());
assert!(!redacted.is_empty());
let real = Contact {
name: Some("Ada Lovelace".into()),
..Contact::default()
};
assert!(!real.is_redacted());
assert!(!Contact::default().is_redacted());
assert!(Contact::default().is_empty());
}
#[test]
fn display_name_falls_back_through_the_useful_fields() {
let organization = Contact {
organization: Some("Example Ltd".into()),
..Contact::default()
};
assert_eq!(organization.display_name(), Some("Example Ltd"));
let handle = Contact {
handle: Some("EX123".into()),
..Contact::default()
};
assert_eq!(handle.display_name(), Some("EX123"));
assert_eq!(Contact::default().display_name(), None);
}
#[test]
fn display_is_compact_and_skips_absent_fields() {
let record = WhoisRecord {
domain: Some(DomainName::parse("example.com").unwrap()),
registrar: Some("Example LLC".into()),
expires: Some(at(2027, 3, 14)),
statuses: vec!["ok".into()],
..WhoisRecord::default()
};
let rendered = record.to_string();
assert!(rendered.contains("domain: example.com"), "{rendered}");
assert!(rendered.contains("expires: 2027-03-14"), "{rendered}");
assert!(!rendered.contains("created"), "{rendered}");
assert!(!rendered.contains("nameservers"), "{rendered}");
}
#[test]
fn round_trips_through_json() {
let record = WhoisRecord {
domain: Some(DomainName::parse("example.com").unwrap()),
created: Some(at(2001, 1, 1)),
statuses: vec!["ok".into()],
name_servers: vec!["ns1.example.com".into()],
dnssec: Some(false),
..WhoisRecord::default()
};
let json = serde_json::to_string(&record).unwrap();
assert_eq!(serde_json::from_str::<WhoisRecord>(&json).unwrap(), record);
}
}