Skip to main content

seer_core/
domain_info.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::lookup::LookupResult;
5use crate::rdap::RdapResponse;
6use crate::whois::WhoisResponse;
7
8/// Indicates which protocol sources contributed to a `DomainInfo`.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10#[serde(rename_all = "lowercase")]
11pub enum DomainInfoSource {
12    /// Both RDAP and WHOIS data were available.
13    Both,
14    /// Only RDAP data was available.
15    Rdap,
16    /// Only WHOIS data was available.
17    Whois,
18    /// Neither protocol returned data; the domain may be available.
19    Available,
20}
21
22impl std::fmt::Display for DomainInfoSource {
23    /// Render the same lowercase form serde uses, so JSON output and any
24    /// non-JSON sink (CSV, logs) carry identical strings.
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        let s = match self {
27            DomainInfoSource::Both => "both",
28            DomainInfoSource::Rdap => "rdap",
29            DomainInfoSource::Whois => "whois",
30            DomainInfoSource::Available => "available",
31        };
32        f.write_str(s)
33    }
34}
35
36/// Number of days before expiration at which a domain is flagged as
37/// "expiring soon" by [`ExpiryStatus`]. Mirrors common registrar grace
38/// windows and the watchlist's near-expiry alerting.
39const EXPIRING_SOON_DAYS: i64 = 30;
40
41/// A coarse lifecycle band for a domain's registration, derived from its
42/// expiration date and EPP status codes.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "kebab-case")]
45pub enum ExpiryStatus {
46    /// Registered and not near expiry.
47    Active,
48    /// Within [`EXPIRING_SOON_DAYS`] of the expiration date.
49    ExpiringSoon,
50    /// Past the expiration date (may still be recoverable — see `Redemption`).
51    Expired,
52    /// In the post-expiry redemption grace period (recoverable by the registrant).
53    Redemption,
54    /// Scheduled for deletion back to the available pool.
55    PendingDelete,
56}
57
58impl std::fmt::Display for ExpiryStatus {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        let s = match self {
61            ExpiryStatus::Active => "active",
62            ExpiryStatus::ExpiringSoon => "expiring-soon",
63            ExpiryStatus::Expired => "expired",
64            ExpiryStatus::Redemption => "redemption",
65            ExpiryStatus::PendingDelete => "pending-delete",
66        };
67        f.write_str(s)
68    }
69}
70
71/// A decoded EPP/RDAP domain status code paired with a plain-English meaning.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct StatusDescription {
74    /// The status code as reported by the registry (e.g. `clientTransferProhibited`).
75    pub code: String,
76    /// A human-readable description of what the code means.
77    pub description: String,
78}
79
80/// Normalizes an EPP or RDAP status token to a comparison key.
81///
82/// WHOIS reports EPP camelCase (`clientTransferProhibited`) while RDAP reports
83/// space-separated lowercase (`client transfer prohibited`); both collapse to
84/// the same key here so [`describe_epp_status`] and `derive_expiry_status`
85/// handle either source. Any trailing ` (URL)` an RDAP server appends is
86/// dropped because only alphanumerics survive the filter.
87fn normalize_status_key(code: &str) -> String {
88    code.chars()
89        .take_while(|c| *c != '(')
90        .filter(|c| c.is_ascii_alphanumeric())
91        .map(|c| c.to_ascii_lowercase())
92        .collect()
93}
94
95/// Decodes a domain status code (EPP or RDAP form) into a plain-English
96/// description, or `None` if the code is unrecognized.
97pub fn describe_epp_status(code: &str) -> Option<&'static str> {
98    let desc = match normalize_status_key(code).as_str() {
99        "ok" | "active" => "no restrictions (standard registered state)",
100        "inactive" => "no nameservers delegated",
101        "addperiod" => "recently registered (add grace period)",
102        "autorenewperiod" => "recently auto-renewed (grace period)",
103        "renewperiod" => "recently renewed (grace period)",
104        "transferperiod" => "recently transferred (grace period)",
105        "redemptionperiod" => "recently expired, recoverable (redemption period)",
106        "pendingrestore" => "restore requested from redemption",
107        "pendingcreate" => "creation requested, pending",
108        "pendingdelete" => "scheduled for deletion",
109        "pendingrenew" => "renewal requested, pending",
110        "pendingtransfer" => "transfer requested, pending",
111        "pendingupdate" => "update requested, pending",
112        "clientdeleteprohibited" => "deletion locked (by registrar)",
113        "clienthold" => "resolution suspended (by registrar)",
114        "clientrenewprohibited" => "renewal locked (by registrar)",
115        "clienttransferprohibited" => "transfer locked (by registrar)",
116        "clientupdateprohibited" => "updates locked (by registrar)",
117        "serverdeleteprohibited" => "deletion locked (by registry)",
118        "serverhold" => "resolution suspended (by registry)",
119        "serverrenewprohibited" => "renewal locked (by registry)",
120        "servertransferprohibited" => "transfer locked (by registry)",
121        "serverupdateprohibited" => "updates locked (by registry)",
122        _ => return None,
123    };
124    Some(desc)
125}
126
127/// Derives a coarse [`ExpiryStatus`] band from the days-until-expiration and the
128/// domain's status codes. Status codes take priority: a `pendingDelete` or
129/// `redemptionPeriod` state describes the lifecycle better than the raw date.
130fn derive_expiry_status(
131    days_until_expiration: Option<i64>,
132    status: &[String],
133) -> Option<ExpiryStatus> {
134    let has = |needle: &str| status.iter().any(|s| normalize_status_key(s) == needle);
135    if has("pendingdelete") {
136        return Some(ExpiryStatus::PendingDelete);
137    }
138    if has("redemptionperiod") {
139        return Some(ExpiryStatus::Redemption);
140    }
141    let days = days_until_expiration?;
142    Some(if days < 0 {
143        ExpiryStatus::Expired
144    } else if days <= EXPIRING_SOON_DAYS {
145        ExpiryStatus::ExpiringSoon
146    } else {
147        ExpiryStatus::Active
148    })
149}
150
151/// A flat, merged view of domain registration data from RDAP and WHOIS.
152///
153/// RDAP fields take priority when both sources are present; WHOIS fills gaps.
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct DomainInfo {
156    pub domain: String,
157    pub source: DomainInfoSource,
158
159    // Registration
160    pub registrar: Option<String>,
161    pub registrant: Option<String>,
162    pub organization: Option<String>,
163
164    // Dates
165    pub creation_date: Option<DateTime<Utc>>,
166    pub expiration_date: Option<DateTime<Utc>>,
167    pub updated_date: Option<DateTime<Utc>>,
168
169    // DNS
170    pub nameservers: Vec<String>,
171    pub status: Vec<String>,
172    pub dnssec: Option<String>,
173
174    // Registrant contact
175    pub registrant_email: Option<String>,
176    pub registrant_phone: Option<String>,
177    pub registrant_address: Option<String>,
178    pub registrant_country: Option<String>,
179
180    // Admin contact
181    pub admin_name: Option<String>,
182    pub admin_organization: Option<String>,
183    pub admin_email: Option<String>,
184    pub admin_phone: Option<String>,
185
186    // Tech contact
187    pub tech_name: Option<String>,
188    pub tech_organization: Option<String>,
189    pub tech_email: Option<String>,
190    pub tech_phone: Option<String>,
191
192    // Protocol metadata
193    pub whois_server: Option<String>,
194    pub rdap_url: Option<String>,
195
196    // Registrar object detail (from the RDAP registrar entity)
197    /// ICANN-required registrar abuse contact email (for takedown reports).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub registrar_abuse_email: Option<String>,
200    /// Registrar abuse contact phone.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub registrar_abuse_phone: Option<String>,
203    /// IANA Registrar ID (uniquely identifies the registrar).
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub registrar_iana_id: Option<String>,
206    /// Registrar URL (the `about` link on the registrar entity).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub registrar_url: Option<String>,
209
210    /// Availability verdict: `"available"` / `"likely_available"` / `"unknown"`
211    /// when derived from a `LookupResult::Available`. `None` otherwise.
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub availability_verdict: Option<String>,
214
215    // --- Derived lifecycle fields (computed at construction) ---
216    /// Days until the registration expires (negative if already past),
217    /// computed from `expiration_date`. `None` when the date is unknown.
218    #[serde(default, skip_serializing_if = "Option::is_none")]
219    pub days_until_expiration: Option<i64>,
220    /// Age of the registration in days, computed from `creation_date`.
221    /// `None` when the creation date is unknown.
222    #[serde(default, skip_serializing_if = "Option::is_none")]
223    pub domain_age_days: Option<i64>,
224    /// Coarse lifecycle band derived from the expiry date and status codes.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub expiry_status: Option<ExpiryStatus>,
227    /// Plain-English decodings of recognized `status` codes.
228    #[serde(default, skip_serializing_if = "Vec::is_empty")]
229    pub status_descriptions: Vec<StatusDescription>,
230}
231
232impl DomainInfo {
233    /// Constructs a `DomainInfo` by merging optional RDAP and WHOIS data.
234    ///
235    /// For each field, RDAP is tried first; WHOIS fills any remaining gaps.
236    /// For vector fields (nameservers, status), RDAP is used if non-empty, else WHOIS.
237    pub fn from_sources(
238        domain: &str,
239        rdap: Option<&RdapResponse>,
240        whois: Option<&WhoisResponse>,
241    ) -> Self {
242        let source = match (rdap.is_some(), whois.is_some()) {
243            (true, true) => DomainInfoSource::Both,
244            (true, false) => DomainInfoSource::Rdap,
245            (false, true) => DomainInfoSource::Whois,
246            (false, false) => DomainInfoSource::Available,
247        };
248
249        // Helper: try RDAP extraction first, then WHOIS field
250        macro_rules! rdap_or_whois {
251            ($rdap_expr:expr, $whois_field:ident) => {
252                $rdap_expr.or_else(|| whois.and_then(|w| w.$whois_field.clone()))
253            };
254        }
255
256        // --- Registration ---
257        let registrar = rdap_or_whois!(rdap.and_then(|r| r.get_registrar()), registrar);
258        let registrant = rdap_or_whois!(rdap.and_then(|r| r.get_registrant()), registrant);
259        let organization = rdap_or_whois!(
260            rdap.and_then(|r| r.get_registrant_organization()),
261            organization
262        );
263
264        // --- Dates ---
265        let creation_date = rdap
266            .and_then(|r| r.creation_date())
267            .or_else(|| whois.and_then(|w| w.creation_date));
268        let expiration_date = rdap
269            .and_then(|r| r.expiration_date())
270            .or_else(|| whois.and_then(|w| w.expiration_date));
271        let updated_date = rdap
272            .and_then(|r| r.last_updated())
273            .or_else(|| whois.and_then(|w| w.updated_date));
274
275        // --- DNS (vec fields: RDAP if non-empty, else WHOIS) ---
276        let rdap_ns = rdap.map(|r| r.nameserver_names()).unwrap_or_default();
277        let nameservers = if !rdap_ns.is_empty() {
278            rdap_ns
279        } else {
280            whois.map(|w| w.nameservers.clone()).unwrap_or_default()
281        };
282
283        let rdap_status = rdap.map(|r| r.status.clone()).unwrap_or_default();
284        let status = if !rdap_status.is_empty() {
285            rdap_status
286        } else {
287            whois.map(|w| w.status.clone()).unwrap_or_default()
288        };
289
290        // DNSSEC: RDAP secure_dns.delegation_signed -> "signed"/"unsigned", else WHOIS
291        let dnssec = rdap
292            .and_then(|r| r.secure_dns.as_ref())
293            .and_then(|sd| sd.delegation_signed)
294            .map(|signed| {
295                if signed {
296                    "signed".to_string()
297                } else {
298                    "unsigned".to_string()
299                }
300            })
301            .or_else(|| whois.and_then(|w| w.dnssec.clone()));
302
303        // --- Registrant contact ---
304        let rdap_registrant_contact = rdap.and_then(|r| r.get_registrant_contact());
305        let registrant_email = rdap_registrant_contact
306            .as_ref()
307            .and_then(|c| c.email.clone())
308            .or_else(|| whois.and_then(|w| w.registrant_email.clone()));
309        let registrant_phone = rdap_registrant_contact
310            .as_ref()
311            .and_then(|c| c.phone.clone())
312            .or_else(|| whois.and_then(|w| w.registrant_phone.clone()));
313        let registrant_address = rdap_registrant_contact
314            .as_ref()
315            .and_then(|c| c.address.clone())
316            .or_else(|| whois.and_then(|w| w.registrant_address.clone()));
317        let registrant_country = rdap_registrant_contact
318            .as_ref()
319            .and_then(|c| c.country.clone())
320            .or_else(|| whois.and_then(|w| w.registrant_country.clone()));
321
322        // --- Admin contact ---
323        let rdap_admin_contact = rdap.and_then(|r| r.get_admin_contact());
324        let admin_name = rdap_admin_contact
325            .as_ref()
326            .and_then(|c| c.name.clone())
327            .or_else(|| whois.and_then(|w| w.admin_name.clone()));
328        let admin_organization = rdap_admin_contact
329            .as_ref()
330            .and_then(|c| c.organization.clone())
331            .or_else(|| whois.and_then(|w| w.admin_organization.clone()));
332        let admin_email = rdap_admin_contact
333            .as_ref()
334            .and_then(|c| c.email.clone())
335            .or_else(|| whois.and_then(|w| w.admin_email.clone()));
336        let admin_phone = rdap_admin_contact
337            .as_ref()
338            .and_then(|c| c.phone.clone())
339            .or_else(|| whois.and_then(|w| w.admin_phone.clone()));
340
341        // --- Tech contact ---
342        let rdap_tech_contact = rdap.and_then(|r| r.get_tech_contact());
343        let tech_name = rdap_tech_contact
344            .as_ref()
345            .and_then(|c| c.name.clone())
346            .or_else(|| whois.and_then(|w| w.tech_name.clone()));
347        let tech_organization = rdap_tech_contact
348            .as_ref()
349            .and_then(|c| c.organization.clone())
350            .or_else(|| whois.and_then(|w| w.tech_organization.clone()));
351        let tech_email = rdap_tech_contact
352            .as_ref()
353            .and_then(|c| c.email.clone())
354            .or_else(|| whois.and_then(|w| w.tech_email.clone()));
355        let tech_phone = rdap_tech_contact
356            .as_ref()
357            .and_then(|c| c.phone.clone())
358            .or_else(|| whois.and_then(|w| w.tech_phone.clone()));
359
360        // --- Protocol metadata ---
361        let rdap_url = rdap.and_then(|r| {
362            r.links
363                .iter()
364                .find(|l| l.rel.as_deref() == Some("self"))
365                .and_then(|l| l.href.clone())
366        });
367
368        let whois_server = whois
369            .map(|w| w.whois_server.clone())
370            .filter(|s| !s.is_empty());
371
372        // Registrar object detail is RDAP-only structured data (abuse contact,
373        // IANA ID, URL). WHOIS carries no equivalently structured fields.
374        let registrar_detail = rdap.and_then(|r| r.get_registrar_detail());
375        let registrar_abuse_email = registrar_detail
376            .as_ref()
377            .and_then(|d| d.abuse_email.clone());
378        let registrar_abuse_phone = registrar_detail
379            .as_ref()
380            .and_then(|d| d.abuse_phone.clone());
381        let registrar_iana_id = registrar_detail.as_ref().and_then(|d| d.iana_id.clone());
382        let registrar_url = registrar_detail.as_ref().and_then(|d| d.url.clone());
383
384        let mut info = DomainInfo {
385            domain: domain.to_string(),
386            source,
387            registrar,
388            registrant,
389            organization,
390            creation_date,
391            expiration_date,
392            updated_date,
393            nameservers,
394            status,
395            dnssec,
396            registrant_email,
397            registrant_phone,
398            registrant_address,
399            registrant_country,
400            admin_name,
401            admin_organization,
402            admin_email,
403            admin_phone,
404            tech_name,
405            tech_organization,
406            tech_email,
407            tech_phone,
408            whois_server,
409            rdap_url,
410            registrar_abuse_email,
411            registrar_abuse_phone,
412            registrar_iana_id,
413            registrar_url,
414            availability_verdict: None,
415            days_until_expiration: None,
416            domain_age_days: None,
417            expiry_status: None,
418            status_descriptions: Vec::new(),
419        };
420        info.compute_lifecycle(Utc::now());
421        info
422    }
423
424    /// Populates the computed lifecycle fields (`days_until_expiration`,
425    /// `domain_age_days`, `expiry_status`, `status_descriptions`) relative to
426    /// `now`. Pure given `now`, so it is unit-testable with a fixed clock.
427    fn compute_lifecycle(&mut self, now: DateTime<Utc>) {
428        self.days_until_expiration = self.expiration_date.map(|e| (e - now).num_days());
429        self.domain_age_days = self.creation_date.map(|c| (now - c).num_days());
430        self.expiry_status = derive_expiry_status(self.days_until_expiration, &self.status);
431        self.status_descriptions = self
432            .status
433            .iter()
434            .filter_map(|code| {
435                describe_epp_status(code).map(|d| StatusDescription {
436                    code: code.clone(),
437                    description: d.to_string(),
438                })
439            })
440            .collect();
441    }
442
443    /// Constructs a `DomainInfo` from a `LookupResult`, extracting RDAP and WHOIS
444    /// data from whatever variant is present.
445    pub fn from_lookup_result(result: &LookupResult) -> Self {
446        match result {
447            LookupResult::Rdap {
448                data,
449                whois_fallback,
450            } => Self::from_sources(
451                data.domain_name().unwrap_or("unknown"),
452                Some(data),
453                whois_fallback.as_ref(),
454            ),
455            LookupResult::Whois {
456                data,
457                rdap_fallback,
458                ..
459            } => Self::from_sources(
460                &data.domain,
461                rdap_fallback.as_ref().map(|b| b.as_ref()),
462                Some(data),
463            ),
464            LookupResult::Available { data, .. } => {
465                let mut info = Self::from_sources(&data.domain, None, None);
466                info.source = DomainInfoSource::Available;
467                info.availability_verdict = Some(data.verdict().to_string());
468                info
469            }
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    /// Build a test RdapResponse with registrar entity, events, nameserver,
479    /// secure_dns, and a self link via JSON deserialization.
480    fn make_test_rdap() -> RdapResponse {
481        serde_json::from_value(serde_json::json!({
482            "objectClassName": "domain",
483            "ldhName": "example.com",
484            "status": ["active"],
485            "events": [
486                {
487                    "eventAction": "registration",
488                    "eventDate": "2020-01-15T00:00:00Z"
489                },
490                {
491                    "eventAction": "expiration",
492                    "eventDate": "2025-01-15T00:00:00Z"
493                },
494                {
495                    "eventAction": "last changed",
496                    "eventDate": "2024-06-01T12:00:00Z"
497                }
498            ],
499            "entities": [
500                {
501                    "objectClassName": "entity",
502                    "roles": ["registrar"],
503                    "vcardArray": [
504                        "vcard",
505                        [
506                            ["version", {}, "text", "4.0"],
507                            ["fn", {}, "text", "RDAP Registrar LLC"]
508                        ]
509                    ]
510                }
511            ],
512            "nameservers": [
513                {
514                    "objectClassName": "nameserver",
515                    "ldhName": "ns1.rdap-example.com"
516                }
517            ],
518            "secureDns": {
519                "delegationSigned": true,
520                "dsData": [],
521                "keyData": []
522            },
523            "links": [
524                {
525                    "rel": "self",
526                    "href": "https://rdap.example.com/domain/example.com"
527                }
528            ]
529        }))
530        .expect("test RDAP JSON should deserialize")
531    }
532
533    /// Build a test WhoisResponse with all fields populated.
534    fn make_test_whois() -> WhoisResponse {
535        use chrono::TimeZone;
536        WhoisResponse {
537            domain: "example.com".to_string(),
538            registrar: Some("WHOIS Registrar Inc.".to_string()),
539            registrant: Some("John Doe".to_string()),
540            organization: Some("Example Corp".to_string()),
541            registrant_email: Some("john@example.com".to_string()),
542            registrant_phone: Some("+1.5551234567".to_string()),
543            registrant_address: Some("123 Main St".to_string()),
544            registrant_country: Some("US".to_string()),
545            admin_name: Some("Admin Person".to_string()),
546            admin_organization: Some("Admin Org".to_string()),
547            admin_email: Some("admin@example.com".to_string()),
548            admin_phone: Some("+1.5559876543".to_string()),
549            tech_name: Some("Tech Person".to_string()),
550            tech_organization: Some("Tech Org".to_string()),
551            tech_email: Some("tech@example.com".to_string()),
552            tech_phone: Some("+1.5555555555".to_string()),
553            creation_date: Some(Utc.with_ymd_and_hms(2019, 6, 1, 0, 0, 0).unwrap()),
554            expiration_date: Some(Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap()),
555            updated_date: Some(Utc.with_ymd_and_hms(2023, 12, 15, 0, 0, 0).unwrap()),
556            nameservers: vec![
557                "ns1.whois-example.com".to_string(),
558                "ns2.whois-example.com".to_string(),
559            ],
560            status: vec!["clientTransferProhibited".to_string()],
561            dnssec: Some("signedDelegation".to_string()),
562            whois_server: "whois.example.com".to_string(),
563            raw_response: "raw whois data".to_string(),
564        }
565    }
566
567    #[test]
568    fn test_from_sources_both() {
569        let rdap = make_test_rdap();
570        let whois = make_test_whois();
571        let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
572
573        assert_eq!(info.source, DomainInfoSource::Both);
574        assert_eq!(info.domain, "example.com");
575
576        // RDAP takes priority for registrar
577        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
578
579        // RDAP has no registrant entity, so WHOIS fills in
580        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
581        assert_eq!(info.organization.as_deref(), Some("Example Corp"));
582
583        // RDAP dates take priority
584        assert!(info.creation_date.is_some());
585        assert_eq!(
586            info.creation_date.unwrap().to_rfc3339(),
587            "2020-01-15T00:00:00+00:00"
588        );
589        assert_eq!(
590            info.expiration_date.unwrap().to_rfc3339(),
591            "2025-01-15T00:00:00+00:00"
592        );
593        assert_eq!(
594            info.updated_date.unwrap().to_rfc3339(),
595            "2024-06-01T12:00:00+00:00"
596        );
597
598        // RDAP nameservers non-empty, so used
599        assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
600
601        // RDAP status non-empty, so used
602        assert_eq!(info.status, vec!["active"]);
603
604        // DNSSEC from RDAP secure_dns
605        assert_eq!(info.dnssec.as_deref(), Some("signed"));
606
607        // Contacts: RDAP has no registrant/admin/tech contact entities, so WHOIS fills
608        assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
609        assert_eq!(info.registrant_phone.as_deref(), Some("+1.5551234567"));
610        assert_eq!(info.registrant_address.as_deref(), Some("123 Main St"));
611        assert_eq!(info.registrant_country.as_deref(), Some("US"));
612        assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
613        assert_eq!(info.admin_organization.as_deref(), Some("Admin Org"));
614        assert_eq!(info.admin_email.as_deref(), Some("admin@example.com"));
615        assert_eq!(info.admin_phone.as_deref(), Some("+1.5559876543"));
616        assert_eq!(info.tech_name.as_deref(), Some("Tech Person"));
617        assert_eq!(info.tech_organization.as_deref(), Some("Tech Org"));
618        assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
619        assert_eq!(info.tech_phone.as_deref(), Some("+1.5555555555"));
620
621        // Protocol metadata
622        assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
623        assert_eq!(
624            info.rdap_url.as_deref(),
625            Some("https://rdap.example.com/domain/example.com")
626        );
627    }
628
629    #[test]
630    fn test_from_sources_whois_only() {
631        let whois = make_test_whois();
632        let info = DomainInfo::from_sources("example.com", None, Some(&whois));
633
634        assert_eq!(info.source, DomainInfoSource::Whois);
635        assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
636        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
637        assert_eq!(info.organization.as_deref(), Some("Example Corp"));
638        assert_eq!(
639            info.creation_date.unwrap().to_rfc3339(),
640            "2019-06-01T00:00:00+00:00"
641        );
642        assert_eq!(
643            info.expiration_date.unwrap().to_rfc3339(),
644            "2024-06-01T00:00:00+00:00"
645        );
646        assert_eq!(
647            info.updated_date.unwrap().to_rfc3339(),
648            "2023-12-15T00:00:00+00:00"
649        );
650        assert_eq!(
651            info.nameservers,
652            vec!["ns1.whois-example.com", "ns2.whois-example.com"]
653        );
654        assert_eq!(info.status, vec!["clientTransferProhibited"]);
655        assert_eq!(info.dnssec.as_deref(), Some("signedDelegation"));
656        assert_eq!(info.registrant_email.as_deref(), Some("john@example.com"));
657        assert_eq!(info.admin_name.as_deref(), Some("Admin Person"));
658        assert_eq!(info.tech_email.as_deref(), Some("tech@example.com"));
659        assert_eq!(info.whois_server.as_deref(), Some("whois.example.com"));
660        assert!(info.rdap_url.is_none());
661    }
662
663    #[test]
664    fn test_from_sources_rdap_only() {
665        let rdap = make_test_rdap();
666        let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
667
668        assert_eq!(info.source, DomainInfoSource::Rdap);
669        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
670        // No registrant entity in our test RDAP, no WHOIS to fall back to
671        assert!(info.registrant.is_none());
672        assert!(info.organization.is_none());
673        assert!(info.creation_date.is_some());
674        assert!(info.expiration_date.is_some());
675        assert!(info.updated_date.is_some());
676        assert_eq!(info.nameservers, vec!["ns1.rdap-example.com"]);
677        assert_eq!(info.status, vec!["active"]);
678        assert_eq!(info.dnssec.as_deref(), Some("signed"));
679        // No contact entities in RDAP test data
680        assert!(info.registrant_email.is_none());
681        assert!(info.admin_name.is_none());
682        assert!(info.tech_email.is_none());
683        assert!(info.whois_server.is_none());
684        assert_eq!(
685            info.rdap_url.as_deref(),
686            Some("https://rdap.example.com/domain/example.com")
687        );
688    }
689
690    #[test]
691    fn test_from_sources_neither() {
692        let info = DomainInfo::from_sources("available.com", None, None);
693
694        assert_eq!(info.source, DomainInfoSource::Available);
695        assert_eq!(info.domain, "available.com");
696        assert!(info.registrar.is_none());
697        assert!(info.registrant.is_none());
698        assert!(info.organization.is_none());
699        assert!(info.creation_date.is_none());
700        assert!(info.expiration_date.is_none());
701        assert!(info.updated_date.is_none());
702        assert!(info.nameservers.is_empty());
703        assert!(info.status.is_empty());
704        assert!(info.dnssec.is_none());
705        assert!(info.registrant_email.is_none());
706        assert!(info.admin_name.is_none());
707        assert!(info.tech_email.is_none());
708        assert!(info.whois_server.is_none());
709        assert!(info.rdap_url.is_none());
710    }
711
712    #[test]
713    fn test_from_lookup_result_rdap_variant() {
714        let rdap = make_test_rdap();
715        let whois = make_test_whois();
716        let result = LookupResult::Rdap {
717            data: Box::new(rdap),
718            whois_fallback: Some(whois),
719        };
720
721        let info = DomainInfo::from_lookup_result(&result);
722        assert_eq!(info.source, DomainInfoSource::Both);
723        assert_eq!(info.domain, "example.com");
724        assert_eq!(info.registrar.as_deref(), Some("RDAP Registrar LLC"));
725        // WHOIS fills gaps
726        assert_eq!(info.registrant.as_deref(), Some("John Doe"));
727    }
728
729    #[test]
730    fn test_from_lookup_result_whois_variant() {
731        let whois = make_test_whois();
732        let result = LookupResult::Whois {
733            data: whois,
734            rdap_error: Some("RDAP failed".to_string()),
735            rdap_fallback: None,
736        };
737
738        let info = DomainInfo::from_lookup_result(&result);
739        assert_eq!(info.source, DomainInfoSource::Whois);
740        assert_eq!(info.domain, "example.com");
741        assert_eq!(info.registrar.as_deref(), Some("WHOIS Registrar Inc."));
742        assert!(info.rdap_url.is_none());
743    }
744
745    #[test]
746    fn from_sources_populates_registrar_detail() {
747        let rdap: RdapResponse = serde_json::from_value(serde_json::json!({
748            "objectClassName": "domain",
749            "ldhName": "example.com",
750            "entities": [{
751                "objectClassName": "entity",
752                "roles": ["registrar"],
753                "publicIds": [{"type": "IANA Registrar ID", "identifier": "292"}],
754                "links": [{"rel": "about", "href": "https://registrar.example"}],
755                "vcardArray": ["vcard", [["fn", {}, "text", "Example Registrar"]]],
756                "entities": [{
757                    "objectClassName": "entity",
758                    "roles": ["abuse"],
759                    "vcardArray": ["vcard", [
760                        ["email", {}, "text", "abuse@registrar.example"],
761                        ["tel", {}, "text", "+1.5551230000"]
762                    ]]
763                }]
764            }]
765        }))
766        .unwrap();
767        let info = DomainInfo::from_sources("example.com", Some(&rdap), None);
768        assert_eq!(info.registrar_iana_id.as_deref(), Some("292"));
769        assert_eq!(
770            info.registrar_url.as_deref(),
771            Some("https://registrar.example")
772        );
773        assert_eq!(
774            info.registrar_abuse_email.as_deref(),
775            Some("abuse@registrar.example")
776        );
777        assert_eq!(info.registrar_abuse_phone.as_deref(), Some("+1.5551230000"));
778    }
779
780    #[test]
781    fn describe_epp_status_decodes_known_codes() {
782        assert_eq!(
783            describe_epp_status("clientTransferProhibited"),
784            Some("transfer locked (by registrar)")
785        );
786        // RDAP space-separated form maps to the same description.
787        assert_eq!(
788            describe_epp_status("client transfer prohibited"),
789            Some("transfer locked (by registrar)")
790        );
791        assert_eq!(
792            describe_epp_status("serverHold"),
793            Some("resolution suspended (by registry)")
794        );
795        assert_eq!(
796            describe_epp_status("redemptionPeriod"),
797            Some("recently expired, recoverable (redemption period)")
798        );
799        // A trailing RDAP URL annotation is ignored.
800        assert_eq!(
801            describe_epp_status("client hold (https://example.com/info)"),
802            Some("resolution suspended (by registrar)")
803        );
804        assert_eq!(describe_epp_status("totally unknown code"), None);
805    }
806
807    #[test]
808    fn derive_expiry_status_bands_by_days_and_codes() {
809        // Status codes win over the date math.
810        assert_eq!(
811            derive_expiry_status(Some(200), &["pendingDelete".into()]),
812            Some(ExpiryStatus::PendingDelete)
813        );
814        assert_eq!(
815            derive_expiry_status(Some(200), &["redemptionPeriod".into()]),
816            Some(ExpiryStatus::Redemption)
817        );
818        // Date bands.
819        assert_eq!(
820            derive_expiry_status(Some(365), &[]),
821            Some(ExpiryStatus::Active)
822        );
823        assert_eq!(
824            derive_expiry_status(Some(30), &[]),
825            Some(ExpiryStatus::ExpiringSoon)
826        );
827        assert_eq!(
828            derive_expiry_status(Some(0), &[]),
829            Some(ExpiryStatus::ExpiringSoon)
830        );
831        assert_eq!(
832            derive_expiry_status(Some(-5), &[]),
833            Some(ExpiryStatus::Expired)
834        );
835        // Unknown date with no informative codes → None.
836        assert_eq!(derive_expiry_status(None, &["ok".into()]), None);
837    }
838
839    #[test]
840    fn compute_lifecycle_uses_fixed_clock() {
841        use chrono::TimeZone;
842        // make_test_whois: creation 2019-06-01, expiration 2024-06-01,
843        // status clientTransferProhibited.
844        let whois = make_test_whois();
845        let mut info = DomainInfo::from_sources("example.com", None, Some(&whois));
846        let now = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap();
847        info.compute_lifecycle(now);
848
849        let expected_days = (Utc.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap() - now).num_days();
850        assert_eq!(info.days_until_expiration, Some(expected_days));
851        assert!(info.days_until_expiration.unwrap() > 0);
852        assert!(info.domain_age_days.unwrap() > 0);
853        // ~152 days out (>30) → Active.
854        assert_eq!(info.expiry_status, Some(ExpiryStatus::Active));
855        // clientTransferProhibited is decoded.
856        assert_eq!(info.status_descriptions.len(), 1);
857        assert_eq!(info.status_descriptions[0].code, "clientTransferProhibited");
858        assert_eq!(
859            info.status_descriptions[0].description,
860            "transfer locked (by registrar)"
861        );
862    }
863
864    #[test]
865    fn test_serialization_round_trip() {
866        let rdap = make_test_rdap();
867        let whois = make_test_whois();
868        let info = DomainInfo::from_sources("example.com", Some(&rdap), Some(&whois));
869
870        let json = serde_json::to_string_pretty(&info).expect("serialize");
871        let deserialized: DomainInfo = serde_json::from_str(&json).expect("deserialize");
872
873        assert_eq!(deserialized.domain, info.domain);
874        assert_eq!(deserialized.source, info.source);
875        assert_eq!(deserialized.registrar, info.registrar);
876        assert_eq!(deserialized.registrant, info.registrant);
877        assert_eq!(deserialized.organization, info.organization);
878        assert_eq!(deserialized.creation_date, info.creation_date);
879        assert_eq!(deserialized.expiration_date, info.expiration_date);
880        assert_eq!(deserialized.updated_date, info.updated_date);
881        assert_eq!(deserialized.nameservers, info.nameservers);
882        assert_eq!(deserialized.status, info.status);
883        assert_eq!(deserialized.dnssec, info.dnssec);
884        assert_eq!(deserialized.registrant_email, info.registrant_email);
885        assert_eq!(deserialized.admin_name, info.admin_name);
886        assert_eq!(deserialized.tech_email, info.tech_email);
887        assert_eq!(deserialized.whois_server, info.whois_server);
888        assert_eq!(deserialized.rdap_url, info.rdap_url);
889
890        // Verify source serializes as lowercase
891        assert!(json.contains("\"source\": \"both\""));
892    }
893}