use std::collections::HashSet;
use chrono::{DateTime, FixedOffset, Utc};
use once_cell::sync::Lazy;
use regex::Regex;
use serde::{Deserialize, Serialize};
static REGISTRAR_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrar:\s*(.+)").expect("Invalid regex for Registrar"),
Regex::new(r"(?i)Registrar Name:\s*(.+)").expect("Invalid regex for Registrar Name"),
Regex::new(r"(?i)Sponsoring Registrar:\s*(.+)")
.expect("Invalid regex for Sponsoring Registrar"),
]
});
static REGISTRANT_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrant Name:\s*(.+)").expect("Invalid regex for Registrant Name"),
Regex::new(r"(?i)Registrant:\s*(.+)").expect("Invalid regex for Registrant"),
]
});
static ORGANIZATION_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrant Organization:\s*(.+)")
.expect("Invalid regex for Registrant Organization"),
Regex::new(r"(?i)Organization:\s*(.+)").expect("Invalid regex for Organization"),
Regex::new(r"(?i)org-name:\s*(.+)").expect("Invalid regex for org-name"),
Regex::new(r"(?i)Org Name:\s*(.+)").expect("Invalid regex for Org Name"),
]
});
static CREATION_DATE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Creation Date:\s*(.+)").expect("Invalid regex for Creation Date"),
Regex::new(r"(?i)Created Date:\s*(.+)").expect("Invalid regex for Created Date"),
Regex::new(r"(?i)Created On:\s*(.+)").expect("Invalid regex for Created On"),
Regex::new(r"(?i)Created:\s*(.+)").expect("Invalid regex for Created"),
Regex::new(r"(?i)Registration Date:\s*(.+)").expect("Invalid regex for Registration Date"),
Regex::new(r"(?i)Domain Registration Date:\s*(.+)")
.expect("Invalid regex for Domain Registration Date"),
]
});
static EXPIRATION_DATE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)(?:Registry )?Expir(?:y|ation) Date:\s*(.+)")
.expect("Invalid regex for Expiry/Expiration Date"),
Regex::new(r"(?i)Expiration Date:\s*(.+)").expect("Invalid regex for Expiration Date"),
Regex::new(r"(?i)Expires On:\s*(.+)").expect("Invalid regex for Expires On"),
Regex::new(r"(?i)Expires:\s*(.+)").expect("Invalid regex for Expires"),
Regex::new(r"(?i)Expiry Date:\s*(.+)").expect("Invalid regex for Expiry Date"),
Regex::new(r"(?i)paid-till:\s*(.+)").expect("Invalid regex for paid-till"),
]
});
static UPDATED_DATE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Updated Date:\s*(.+)").expect("Invalid regex for Updated Date"),
Regex::new(r"(?i)Last Updated On:\s*(.+)").expect("Invalid regex for Last Updated On"),
Regex::new(r"(?i)Last Modified:\s*(.+)").expect("Invalid regex for Last Modified"),
Regex::new(r"(?i)Last Update:\s*(.+)").expect("Invalid regex for Last Update"),
Regex::new(r"(?i)Modified:\s*(.+)").expect("Invalid regex for Modified"),
]
});
static DNSSEC_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)DNSSEC:\s*(.+)").expect("Invalid regex for DNSSEC"),
Regex::new(r"(?i)DNSSEC Status:\s*(.+)").expect("Invalid regex for DNSSEC Status"),
]
});
static NAMESERVER_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Name Server:\s*(.+)").expect("Invalid regex for Name Server"),
Regex::new(r"(?i)Nameserver:\s*(.+)").expect("Invalid regex for Nameserver"),
Regex::new(r"(?i)nserver:\s*(.+)").expect("Invalid regex for nserver"),
Regex::new(r"(?im)^NS:\s+(.+)$").expect("Invalid regex for NS"),
]
});
static REGISTRANT_EMAIL_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrant Email:\s*(.+)").expect("Invalid regex for Registrant Email"),
Regex::new(r"(?i)Registrant E-mail:\s*(.+)").expect("Invalid regex for Registrant E-mail"),
]
});
static REGISTRANT_PHONE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrant Phone:\s*(.+)").expect("Invalid regex for Registrant Phone"),
Regex::new(r"(?i)Registrant Tel:\s*(.+)").expect("Invalid regex for Registrant Tel"),
]
});
static REGISTRANT_ADDRESS_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Registrant Street:\s*(.+)").expect("Invalid regex for Registrant Street"),
Regex::new(r"(?i)Registrant Address:\s*(.+)")
.expect("Invalid regex for Registrant Address"),
]
});
static REGISTRANT_COUNTRY_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![Regex::new(r"(?i)Registrant Country:\s*(.+)")
.expect("Invalid regex for Registrant Country")]
});
static ADMIN_NAME_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Admin Name:\s*(.+)").expect("Invalid regex for Admin Name"),
Regex::new(r"(?i)Administrative Contact Name:\s*(.+)")
.expect("Invalid regex for Administrative Contact Name"),
]
});
static ADMIN_ORG_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![Regex::new(r"(?i)Admin Organization:\s*(.+)")
.expect("Invalid regex for Admin Organization")]
});
static ADMIN_EMAIL_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Admin Email:\s*(.+)").expect("Invalid regex for Admin Email"),
Regex::new(r"(?i)Admin E-mail:\s*(.+)").expect("Invalid regex for Admin E-mail"),
]
});
static ADMIN_PHONE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Admin Phone:\s*(.+)").expect("Invalid regex for Admin Phone"),
Regex::new(r"(?i)Admin Tel:\s*(.+)").expect("Invalid regex for Admin Tel"),
]
});
static TECH_NAME_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Tech Name:\s*(.+)").expect("Invalid regex for Tech Name"),
Regex::new(r"(?i)Technical Contact Name:\s*(.+)")
.expect("Invalid regex for Technical Contact Name"),
]
});
static TECH_ORG_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![Regex::new(r"(?i)Tech Organization:\s*(.+)").expect("Invalid regex for Tech Organization")]
});
static TECH_EMAIL_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Tech Email:\s*(.+)").expect("Invalid regex for Tech Email"),
Regex::new(r"(?i)Tech E-mail:\s*(.+)").expect("Invalid regex for Tech E-mail"),
]
});
static TECH_PHONE_PATTERNS: Lazy<Vec<Regex>> = Lazy::new(|| {
vec![
Regex::new(r"(?i)Tech Phone:\s*(.+)").expect("Invalid regex for Tech Phone"),
Regex::new(r"(?i)Tech Tel:\s*(.+)").expect("Invalid regex for Tech Tel"),
]
});
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhoisResponse {
pub domain: String,
pub registrar: Option<String>,
pub registrant: Option<String>,
pub organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registrant_country: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub admin_phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_organization: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tech_phone: Option<String>,
pub creation_date: Option<DateTime<Utc>>,
pub expiration_date: Option<DateTime<Utc>>,
pub updated_date: Option<DateTime<Utc>>,
pub nameservers: Vec<String>,
pub status: Vec<String>,
pub dnssec: Option<String>,
pub whois_server: String,
#[serde(skip_serializing)]
pub raw_response: String,
}
impl WhoisResponse {
pub fn parse(domain: &str, whois_server: &str, raw: &str) -> Self {
super::parsers::PARSER_REGISTRY.parse(domain, whois_server, raw)
}
pub fn parse_internal(domain: &str, whois_server: &str, raw: &str) -> Self {
let registrar = extract_field_with_patterns(raw, ®ISTRAR_PATTERNS);
let registrant = extract_field_with_patterns(raw, ®ISTRANT_PATTERNS);
let organization = extract_field_with_patterns(raw, &ORGANIZATION_PATTERNS);
let registrant_email = extract_field_with_patterns(raw, ®ISTRANT_EMAIL_PATTERNS);
let registrant_phone = extract_field_with_patterns(raw, ®ISTRANT_PHONE_PATTERNS);
let registrant_address = extract_field_with_patterns(raw, ®ISTRANT_ADDRESS_PATTERNS);
let registrant_country = extract_field_with_patterns(raw, ®ISTRANT_COUNTRY_PATTERNS);
let admin_name = extract_field_with_patterns(raw, &ADMIN_NAME_PATTERNS);
let admin_organization = extract_field_with_patterns(raw, &ADMIN_ORG_PATTERNS);
let admin_email = extract_field_with_patterns(raw, &ADMIN_EMAIL_PATTERNS);
let admin_phone = extract_field_with_patterns(raw, &ADMIN_PHONE_PATTERNS);
let tech_name = extract_field_with_patterns(raw, &TECH_NAME_PATTERNS);
let tech_organization = extract_field_with_patterns(raw, &TECH_ORG_PATTERNS);
let tech_email = extract_field_with_patterns(raw, &TECH_EMAIL_PATTERNS);
let tech_phone = extract_field_with_patterns(raw, &TECH_PHONE_PATTERNS);
let creation_date = extract_date_with_patterns(raw, &CREATION_DATE_PATTERNS);
let expiration_date = extract_date_with_patterns(raw, &EXPIRATION_DATE_PATTERNS);
let updated_date = extract_date_with_patterns(raw, &UPDATED_DATE_PATTERNS);
let nameservers = extract_nameservers(raw);
let status = extract_status_top_level(raw);
let dnssec = extract_field_with_patterns(raw, &DNSSEC_PATTERNS);
WhoisResponse {
domain: domain.to_string(),
registrar,
registrant,
organization,
registrant_email,
registrant_phone,
registrant_address,
registrant_country,
admin_name,
admin_organization,
admin_email,
admin_phone,
tech_name,
tech_organization,
tech_email,
tech_phone,
creation_date,
expiration_date,
updated_date,
nameservers,
status,
dnssec,
whois_server: whois_server.to_string(),
raw_response: raw.to_string(),
}
}
pub fn has_core_data(&self) -> bool {
let has_dates_or_status = self.creation_date.is_some()
|| self.expiration_date.is_some()
|| !self.status.is_empty();
self.registrar.is_some() && has_dates_or_status && !self.nameservers.is_empty()
}
pub fn registry_unavailable(&self) -> bool {
if self.registrar.is_some()
|| self.creation_date.is_some()
|| self.expiration_date.is_some()
|| !self.nameservers.is_empty()
|| !self.status.is_empty()
{
return false;
}
const SERVICE_ERROR_SENTINELS: &[&str] = &[
"tld is not supported",
"this tld is not",
"malformed request",
"invalid query",
"no whois server is known",
"this server does not",
];
let lower = self.raw_response.to_lowercase();
lower.lines().any(|line| {
let t = line.trim_start();
SERVICE_ERROR_SENTINELS.iter().any(|s| t.starts_with(s))
})
}
pub fn is_available(&self) -> bool {
if self.registrar.is_some()
|| self.creation_date.is_some()
|| self.expiration_date.is_some()
|| !self.nameservers.is_empty()
{
return false;
}
for line in self.raw_response.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with('%') {
continue;
}
let lower = trimmed.to_lowercase();
let normalized = lower.split_whitespace().collect::<Vec<_>>().join(" ");
if AVAILABILITY_PATTERNS.iter().any(|p| normalized.contains(p)) {
return true;
}
}
false
}
pub fn indicates_not_found(&self) -> bool {
let lower = self.raw_response.to_lowercase();
lower.lines().any(|line| {
let t = line.trim_start();
NOT_FOUND_PATTERNS.iter().any(|p| t.starts_with(p))
})
}
}
const AVAILABILITY_PATTERNS: &[&str] = &[
"no match for",
"no match",
"not found",
"no data found",
"no entries found",
"domain not found",
"available for registration",
"not registered",
"not been registered",
"status: available",
"status: free",
"no object found",
"does not exist",
"nothing found", "no found", "no record found", "no information was found", "object not found", "not find matchingrecord", ];
const NOT_FOUND_PATTERNS: &[&str] = &[
"no match for",
"domain not found",
"no data found",
"queried object does not exist",
"object does not exist",
"not found:",
"status: free",
"domain is not registered",
];
fn extract_field_with_patterns(text: &str, patterns: &[Regex]) -> Option<String> {
for re in patterns {
if let Some(caps) = re.captures(text) {
if let Some(m) = caps.get(1) {
let value = m.as_str().trim();
if value.is_empty() {
continue;
}
let lower = value.to_lowercase();
let is_redacted = lower.contains("redacted")
|| lower.contains("data protected")
|| lower.contains("privacy")
|| lower.contains("not disclosed")
|| lower.contains("withheld")
|| lower == "n/a"
|| lower == "none";
if !is_redacted {
return Some(value.to_string());
}
}
}
}
None
}
fn extract_date_with_patterns(text: &str, patterns: &[Regex]) -> Option<DateTime<Utc>> {
let date_str = extract_field_with_patterns(text, patterns)?;
parse_date(&date_str)
}
fn parse_date(date_str: &str) -> Option<DateTime<Utc>> {
let cleaned = date_str
.trim()
.replace(" UTC", "Z")
.replace(" (UTC)", "")
.replace(" +0000", "Z");
if let Ok(dt) = DateTime::parse_from_rfc3339(&cleaned) {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(&cleaned, "%Y-%m-%dT%H:%M:%S%z") {
return Some(dt.with_timezone(&Utc));
}
if let Ok(dt) = DateTime::<FixedOffset>::parse_from_str(&cleaned, "%Y-%m-%d %H:%M:%S%z") {
return Some(dt.with_timezone(&Utc));
}
let naive_formats = [
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S%.fZ",
"%Y-%m-%d %H:%M:%S",
"%d-%b-%Y %H:%M:%S",
"%d-%b-%Y %H:%M:%S%.f",
"%d-%b-%Y %H:%M:%SZ",
"%d-%b-%Y %H:%M:%S UTC",
"%Y-%m-%d",
"%d-%b-%Y",
"%d-%B-%Y",
"%Y.%m.%d",
"%Y/%m/%d",
"%d.%m.%Y",
"%d/%m/%Y",
"%b %d %Y",
];
for fmt in &naive_formats {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(&cleaned, fmt) {
return Some(dt.and_utc());
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(&cleaned, fmt) {
return Some(d.and_hms_opt(0, 0, 0)?.and_utc());
}
}
if let Ok(dt) = cleaned.parse::<DateTime<Utc>>() {
return Some(dt);
}
None
}
const MAX_NAMESERVERS: usize = 32;
fn extract_nameservers(text: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut nameservers = Vec::new();
for re in NAMESERVER_PATTERNS.iter() {
for caps in re.captures_iter(text) {
if nameservers.len() >= MAX_NAMESERVERS {
return nameservers;
}
if let Some(m) = caps.get(1) {
let raw = m.as_str().trim();
let ns = raw.split_whitespace().next().unwrap_or(raw).to_lowercase();
if !ns.is_empty() && seen.insert(ns.clone()) {
nameservers.push(ns);
}
}
}
}
nameservers
}
const MAX_STATUSES: usize = 32;
fn extract_status_top_level(raw: &str) -> Vec<String> {
let mut seen = HashSet::new();
let mut statuses = Vec::new();
for line in raw.lines() {
if statuses.len() >= MAX_STATUSES {
break;
}
let trimmed = line.trim_start();
if trimmed.starts_with('[') && trimmed.contains(']') {
break;
}
if trimmed.is_empty() || trimmed.starts_with('%') || trimmed.starts_with('#') {
continue;
}
let lower = trimmed.to_lowercase();
let value_opt = if lower.starts_with("domain status:") {
Some(&trimmed["domain status:".len()..])
} else if lower.starts_with("status:") {
Some(&trimmed["status:".len()..])
} else if lower.starts_with("state:") {
Some(&trimmed["state:".len()..])
} else {
None
};
if let Some(rest) = value_opt {
let raw_val = rest.trim();
if let Some(first) = raw_val.split_whitespace().next() {
if !first.is_empty() && seen.insert(first.to_string()) {
statuses.push(first.to_string());
}
}
}
}
statuses
}
#[cfg(test)]
mod tests {
use super::*;
fn make_response(raw: &str) -> WhoisResponse {
WhoisResponse::parse_internal("example.jp", "whois.jprs.jp", raw)
}
#[test]
fn is_available_jprs_style_with_notice_preamble() {
let raw = "\
Notice: JPRS database is provided for information purposes only.
Notice: Use of this service is subject to the JPRS terms.
Notice: For more info see https://jprs.jp/
Notice: Copyright (C) JPRS.
No match!!
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_nic_br_style() {
let raw = "\
% Copyright (c) Nic.br
% The use of the data below is only permitted as described in
% full by the terms of use at https://registro.br/termo/en.html,
% being prohibited its distribution, commercialization or
% reproduction, in particular, to use it for advertising or
% any similar purpose.
No match for domain exemplo.br.
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_twnic_style() {
let raw = "\
TWNIC WHOIS Server. This service is free and is provided as is.
Notice: Use of this service is subject to terms.
Notice: Do not use for spam.
Domain not found.
";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_false_when_registration_data_present_despite_noise() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
Registrar Abuse Contact: please report if the object not found in directory
";
assert!(!make_response(raw).is_available());
}
#[test]
fn is_available_false_for_registered_domain() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
";
assert!(!make_response(raw).is_available());
}
#[test]
fn registry_unavailable_true_for_tld_not_supported_sentinel() {
let raw = "\
TLD is not supported.
>>> Last update of WHOIS database: 2026-06-03T22:45:28Z <<<
Terms of Use: Access to WHOIS information is provided to assist persons ...
";
assert!(make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_true_for_malformed_request_sentinel() {
let raw = "\
Malformed request.
>>> Last update of WHOIS database: 2026-06-03T22:46:35Z <<<
";
assert!(make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_false_for_registered_domain() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
";
assert!(!make_response(raw).registry_unavailable());
}
#[test]
fn registry_unavailable_false_for_available_domain() {
let raw = "No match for domain EXAMPLE.\n";
assert!(!make_response(raw).registry_unavailable());
}
#[test]
fn extract_status_does_not_panic_on_unicode_lowercasing_expansion() {
let raw = format!("Domain Status: {}\n", "İ".repeat(40));
let r = make_response(&raw); assert!(
!r.status.is_empty(),
"status value should still be extracted"
);
}
#[test]
fn is_available_hkirc_has_not_been_registered() {
let raw = "The domain has not been registered.\n";
assert!(make_response(raw).is_available());
}
#[test]
fn is_available_tab_delimited_status_available() {
let raw = "Domain:\tfoo.be\nStatus:\tAVAILABLE\n";
assert!(make_response(raw).is_available());
}
#[test]
fn is_not_available_tab_delimited_status_not_available() {
let raw = "Domain:\tfoo.be\nStatus:\tNOT AVAILABLE\n";
assert!(!make_response(raw).is_available());
}
#[test]
fn is_available_recognizes_additional_registry_phrasings() {
for raw in [
"*** Nothing found for this query.\n", "No Found\n", "No record found for 'example.ls'.\n", "No information was found matching that query.\n", "Object not found\n", "Not find MatchingRecord\n", ] {
assert!(
make_response(raw).is_available(),
"should detect available from: {raw:?}"
);
}
}
#[test]
fn is_available_does_not_match_registered_or_blocked_phrasings() {
for raw in [
"Domain Status: clientTransferProhibited\nRegistrar: Example, Inc.\n",
"Status: NOT AVAILABLE\n",
"Requests of this client are not permitted. Please use the web form.\n",
"This WHOIS service is free for personal, non-commercial use.\n",
] {
assert!(
!make_response(raw).is_available(),
"must NOT detect available from: {raw:?}"
);
}
}
#[test]
fn indicates_not_found_true_on_line_start() {
let raw = "\
Domain Name: example.com
queried object does not exist
";
assert!(make_response(raw).indicates_not_found());
}
#[test]
fn indicates_not_found_false_when_phrase_is_in_tos_footer() {
let raw = "\
Domain Name: example.com
Registrar: Example Registrar, Inc.
Creation Date: 2020-01-01T00:00:00Z
Name Server: ns1.example.com
Terms of Service:
Note that if the queried object does not exist in our database we return NXDOMAIN.
This document does not imply anything about specific domains.
";
assert!(!make_response(raw).indicates_not_found());
}
#[test]
fn status_extracted_only_from_top_level_block() {
let raw = "\
Domain Name: example.jp
Status: Active
Registrar: Example Registrar
[Tech-C]
Status: ok
Name: Technical Contact
";
let parsed = make_response(raw);
assert_eq!(parsed.status, vec!["Active".to_string()]);
}
#[test]
fn status_multiple_top_level_values_deduped() {
let raw = "\
Domain Name: example.com
Domain Status: clientTransferProhibited
Domain Status: clientUpdateProhibited
Domain Status: clientTransferProhibited
";
let parsed = make_response(raw);
assert_eq!(
parsed.status,
vec![
"clientTransferProhibited".to_string(),
"clientUpdateProhibited".to_string(),
]
);
}
#[test]
fn parse_date_handles_d_b_y_with_time() {
let parsed = parse_date("15-Jan-2024 10:30:00").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
assert_eq!(parsed.month(), 1);
assert_eq!(parsed.day(), 15);
}
#[test]
fn parse_date_handles_d_b_y_with_time_utc_suffix() {
let parsed = parse_date("15-Jan-2024 10:30:00 UTC").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
}
#[test]
fn parse_date_still_handles_d_b_y_date_only() {
let parsed = parse_date("15-Jan-2024").expect("should parse");
use chrono::Datelike;
assert_eq!(parsed.year(), 2024);
}
#[test]
fn raw_response_is_skipped_from_json_output() {
let raw = "Domain Name: example.com\nRegistrar: Example Registrar\n";
let parsed = make_response(raw);
assert!(
!parsed.raw_response.is_empty(),
"raw_response still populated internally"
);
let json = serde_json::to_string(&parsed).expect("serialize");
assert!(
!json.contains("raw_response"),
"raw_response must not appear in serialized JSON output: {}",
json
);
assert!(
!json.contains("Domain Name: example.com"),
"raw response content must not leak in JSON: {}",
json
);
}
}