use super::objects::{class, find_by_handle, first, objects, values, Object};
use super::{push_bounded, MAX_STATUSES};
use crate::whois::parser::{parse_date, WhoisResponse};
pub(super) const TLDS: &[&str] = &["fr", "pm", "re", "tf", "wf", "yt"];
pub(super) fn parse(domain: &str, server: &str, raw: &str) -> WhoisResponse {
let mut response = WhoisResponse::parse_internal(domain, server, raw);
let objects = objects(raw);
let Some(dom) = objects.iter().find(|o| class(o) == Some("domain")) else {
return response;
};
if let Some(date) = first(dom, "created").and_then(parse_date) {
response.creation_date = Some(date);
}
response.updated_date = first(dom, "last-update").and_then(parse_date);
let mut status = Vec::new();
for value in values(dom, "status").chain(values(dom, "eppstatus")) {
push_bounded(&mut status, value.to_string(), MAX_STATUSES);
}
response.status = status;
if objects.iter().any(|o| class(o) == Some("key1-tag")) {
response.dnssec = Some("signedDelegation".to_string());
}
let contact = |role: &str| {
let handle = first(dom, role)?;
find_by_handle(&objects, "nic-hdl", handle).filter(|c| !is_anonymous(c))
};
if let Some(holder) = contact("holder-c") {
response.registrant = first(holder, "contact").map(str::to_string);
response.organization = is_organization(holder)
.then(|| response.registrant.clone())
.flatten();
response.registrant_email = first(holder, "e-mail").map(str::to_string);
response.registrant_phone = first(holder, "phone").map(str::to_string);
response.registrant_country = first(holder, "country").map(str::to_string);
}
if let Some(admin) = contact("admin-c") {
response.admin_name = first(admin, "contact").map(str::to_string);
response.admin_email = first(admin, "e-mail").map(str::to_string);
response.admin_phone = first(admin, "phone").map(str::to_string);
}
if let Some(tech) = contact("tech-c") {
response.tech_name = first(tech, "contact").map(str::to_string);
response.tech_email = first(tech, "e-mail").map(str::to_string);
response.tech_phone = first(tech, "phone").map(str::to_string);
}
response
}
fn is_anonymous(contact: &Object<'_>) -> bool {
first(contact, "anonymous").is_some_and(|v| v.eq_ignore_ascii_case("yes"))
}
fn is_organization(contact: &Object<'_>) -> bool {
first(contact, "type").is_some_and(|v| v.eq_ignore_ascii_case("organization"))
}
#[cfg(test)]
mod tests {
use super::*;
const NIC_FR: &str = concat!(
"%%\n%% This is the AFNIC Whois server.\n%%\n\n",
"domain: nic.fr\n",
"status: ACTIVE\n",
"eppstatus: serverTransferProhibited\n",
"eppstatus: serverDeleteProhibited\n",
"hold: NO\n",
"holder-c: NF100-FRNIC\n",
"admin-c: AFNI30-FRNIC\n",
"tech-c: CTC7206429-FRNIC\n",
"registrar: Registry Operations\n",
"Expiry Date: 2029-12-31T23:00:00Z\n",
"created: 1995-01-01T00:00:00Z\n",
"last-update: 2026-06-05T07:20:46.286415Z\n",
"source: FRNIC\n\n",
"nserver: ns1.ext.nic.fr\n",
"nserver: ns4.nic.fr\n",
"source: FRNIC\n\n",
"key1-tag: 30098\n",
"key1-algo: 13 [ECDSAP256SHA256]\n",
"source: FRNIC\n\n",
"registrar: Registry Operations\n",
"country: FR\n",
"registered: 2014-07-29T15:35:33Z\n",
"source: FRNIC\n\n",
"nic-hdl: NF100-FRNIC\n",
"type: ORGANIZATION\n",
"contact: AFNIC\n",
"country: FR\n",
"phone: +33.139308300\n",
"e-mail: juridique@afnic.fr\n",
"anonymous: NO\n",
"eppstatus: associated\n",
"eppstatus: active\n",
"source: FRNIC\n\n",
"nic-hdl: AFNI30-FRNIC\n",
"type: ORGANIZATION\n",
"contact: ASS FRANC NOMMAGE INTERNET EN COOP\n",
"e-mail: contact@nic.fr\n",
"changed: 2024-04-11T13:45:52.051456Z\n",
"anonymous: NO\n\n",
"nic-hdl: CTC7206429-FRNIC\n",
"type: ORGANIZATION\n",
"contact: AFNIC\n",
"e-mail: dnsops@afnic.fr\n",
"anonymous: NO\n\n",
">>> Last update of WHOIS database: 2026-10-13T16:30:42.159104Z <<<\n",
);
fn ymd(d: Option<chrono::DateTime<chrono::Utc>>) -> Option<String> {
d.map(|d| d.format("%Y-%m-%d").to_string())
}
#[test]
fn dates_come_from_the_domain_object() {
let r = parse("nic.fr", "whois.nic.fr", NIC_FR);
assert_eq!(ymd(r.creation_date).as_deref(), Some("1995-01-01"));
assert_eq!(ymd(r.expiration_date).as_deref(), Some("2029-12-31"));
assert_eq!(ymd(r.updated_date).as_deref(), Some("2026-06-05"));
}
#[test]
fn status_includes_epp_statuses_but_not_contacts() {
let r = parse("nic.fr", "whois.nic.fr", NIC_FR);
assert_eq!(
r.status,
vec![
"ACTIVE",
"serverTransferProhibited",
"serverDeleteProhibited"
]
);
}
#[test]
fn contact_handles_resolve_to_their_objects() {
let r = parse("nic.fr", "whois.nic.fr", NIC_FR);
assert_eq!(r.registrant.as_deref(), Some("AFNIC"));
assert_eq!(r.organization.as_deref(), Some("AFNIC"));
assert_eq!(r.registrant_email.as_deref(), Some("juridique@afnic.fr"));
assert_eq!(r.registrant_country.as_deref(), Some("FR"));
assert_eq!(
r.admin_name.as_deref(),
Some("ASS FRANC NOMMAGE INTERNET EN COOP")
);
assert_eq!(r.tech_email.as_deref(), Some("dnsops@afnic.fr"));
}
#[test]
fn ds_key_object_means_signed() {
let r = parse("nic.fr", "whois.nic.fr", NIC_FR);
assert_eq!(r.dnssec.as_deref(), Some("signedDelegation"));
let unsigned = NIC_FR.replace("key1-tag:", "xkey1-tag:");
assert!(parse("nic.fr", "whois.nic.fr", &unsigned).dnssec.is_none());
}
#[test]
fn generic_fields_still_parse() {
let r = parse("nic.fr", "whois.nic.fr", NIC_FR);
assert_eq!(r.registrar.as_deref(), Some("Registry Operations"));
assert_eq!(r.nameservers, vec!["ns1.ext.nic.fr", "ns4.nic.fr"]);
assert!(r.has_core_data());
}
#[test]
fn anonymous_holder_is_not_read() {
let raw = concat!(
"domain: example.fr\nholder-c: ANO00-FRNIC\nstatus: ACTIVE\n\n",
"nic-hdl: ANO00-FRNIC\ntype: PERSON\ncontact: Ano Nymous\n",
"e-mail: ano@example.fr\nanonymous: YES\n",
);
let r = parse("example.fr", "whois.nic.fr", raw);
assert!(r.registrant.is_none());
assert!(r.registrant_email.is_none());
}
#[test]
fn crlf_body_parses_like_lf() {
let r = parse("nic.fr", "whois.nic.fr", &NIC_FR.replace('\n', "\r\n"));
assert_eq!(ymd(r.updated_date).as_deref(), Some("2026-06-05"));
assert_eq!(r.registrant.as_deref(), Some("AFNIC"));
}
}