monovm-whois-rust 1.0.0

Domain WHOIS and RDAP lookups with availability detection, structured record parsing, referral chasing, caching and rate limiting.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
//! [`KeyValueParser`]: reading the `Key: value` records port 43 returns.

use std::collections::BTreeMap;

use crate::domain::DomainName;
use crate::error::Result;
use crate::parser::{parse_datetime, Contact, RecordParser, WhoisRecord};
use crate::transport::{RawResponse, ResponseKind};

/// Characters registries use to pad a key out to a fixed width.
const PADDING: [char; 4] = ['.', '_', '-', ' '];

/// Line prefixes that mark a banner rather than a field.
const COMMENT_PREFIXES: [&str; 5] = ["%", "#", ";", ">>>", "--"];

/// Values that mean "this field is empty", written as though it were not.
const NULL_VALUES: [&str; 9] = [
    "n/a",
    "na",
    "none",
    "not applicable",
    "not available",
    "unknown",
    "-",
    "null",
    "no data",
];

/// Longest a key may be before the line is treated as prose.
const MAX_KEY_LEN: usize = 48;

/// Most words a key may have before the line is treated as prose.
///
/// Four covers the longest real field names — `Registrar Abuse Contact Email`,
/// `Registrar Registration Expiration Date` — while rejecting clauses like
/// "is currently set to expire:", which appears mid-sentence in Verisign's disclaimer
/// and was otherwise parsed as a field.
const MAX_KEY_WORDS: usize = 4;

/// Keys that head a paragraph of legal text rather than name a field.
///
/// `NOTICE: The expiration date displayed in this record is …` is `key: value` shaped
/// and is not a field. Left in, it made an *available* domain's response parse into a
/// non-empty record, which is exactly the kind of quiet wrongness this crate is about.
const PROSE_KEYS: [&str; 11] = [
    "notice",
    "note",
    "notes",
    "terms of use",
    "terms and conditions",
    "terms",
    "important",
    "disclaimer",
    "warning",
    "attention",
    "copyright",
];

/// Most words an *unrecognised* field's value may have before it is treated as prose.
///
/// Recognised fields are exempt: a registrant's street address can be long, and a key
/// this crate knows by name is a field whatever its value looks like. But an unknown
/// key with a sentence after it is a sentence.
const MAX_UNKNOWN_VALUE_WORDS: usize = 12;

/// A parser for the line-oriented records port 43 servers return.
///
/// Handles the two layouts registries actually use. Most write the value on the
/// same line:
///
/// ```text
/// Domain Name: EXAMPLE.COM
/// Registrar: Example Registrar, LLC
/// ```
///
/// Nominet and several others put it on the next one, indented:
///
/// ```text
///     Domain name:
///         example.co.uk
/// ```
///
/// Both produce the same [`WhoisRecord`]. Keys are matched after padding is
/// stripped, so `Expiry Date`, `expiry date` and `expiry.date........` are one
/// field, and anything unrecognised is kept in
/// [`WhoisRecord::extra`](crate::parser::WhoisRecord::extra) rather than dropped.
///
/// ```
/// use monovm_whois::parser::KeyValueParser;
///
/// let record = KeyValueParser::new().parse_text("\
/// Domain Name: EXAMPLE.COM
/// Registrar: Example Registrar, LLC
/// Creation Date: 1995-08-14T04:00:00Z
/// Domain Status: clientTransferProhibited
/// Name Server: NS1.EXAMPLE.COM
/// Name Server: NS2.EXAMPLE.COM
/// ");
///
/// assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.com");
/// assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
/// assert_eq!(record.name_servers, ["ns1.example.com", "ns2.example.com"]);
/// assert!(record.is_transfer_locked());
/// ```
#[derive(Debug, Clone, Copy, Default)]
pub struct KeyValueParser {
    _private: (),
}

impl KeyValueParser {
    /// A parser.
    pub fn new() -> Self {
        KeyValueParser { _private: () }
    }

    /// Parse text directly, without a [`RawResponse`] wrapper.
    ///
    /// Never fails: an unrecognisable record produces an empty [`WhoisRecord`],
    /// which is the honest outcome. Reporting a parse error would tell a caller
    /// something is broken when the truth is that this registry publishes nothing.
    pub fn parse_text(&self, text: &str) -> WhoisRecord {
        let mut record = WhoisRecord::new();
        let mut extra: BTreeMap<String, Vec<String>> = BTreeMap::new();
        let mut pending_key: Option<String> = None;

        for raw_line in text.lines() {
            let line = raw_line.trim_end();
            let trimmed = line.trim();

            if trimmed.is_empty() {
                pending_key = None;
                continue;
            }
            if COMMENT_PREFIXES
                .iter()
                .any(|prefix| trimmed.starts_with(prefix))
            {
                pending_key = None;
                continue;
            }

            match split_field(trimmed) {
                Some((key, value)) => {
                    let key = normalise_key(key);
                    if key.is_empty() {
                        continue;
                    }

                    if value.is_empty() {
                        // A header, whose value is on the following lines.
                        pending_key = Some(key);
                    } else {
                        pending_key = None;
                        assign(&mut record, &mut extra, &key, value);
                    }
                }
                None => {
                    // A continuation of the previous header, e.g. Nominet's layout.
                    if let Some(key) = pending_key.clone() {
                        assign(&mut record, &mut extra, &key, trimmed);
                    }
                }
            }
        }

        record.extra = extra;
        tidy(&mut record);
        record
    }
}

impl RecordParser for KeyValueParser {
    fn name(&self) -> &'static str {
        "key-value"
    }

    fn can_parse(&self, response: &RawResponse) -> bool {
        response.kind() == ResponseKind::WhoisText
    }

    fn parse(&self, response: &RawResponse) -> Result<WhoisRecord> {
        Ok(self.parse_text(response.text()))
    }
}

/// Split a line into a key and a value, or decide it is not a field at all.
///
/// The guards matter more than the split. `http://www.example.com` contains a
/// colon and is a value, not a field, and a sentence with a colon in it is prose.
/// Getting this wrong fills `extra` with garbage and, worse, can shadow a real
/// field.
fn split_field(line: &str) -> Option<(&str, &str)> {
    let (key, value) = line.split_once(':')?;

    // `scheme://host` — the colon belongs to the value.
    if value.starts_with("//") {
        return None;
    }
    let key = key.trim();
    if key.is_empty() || key.len() > MAX_KEY_LEN {
        return None;
    }
    // A key is a short label, not a clause. A path separator or a sentence's worth
    // of words means this line is prose that happens to contain a colon.
    if key.contains('/') || key.split_whitespace().count() > MAX_KEY_WORDS {
        return None;
    }
    if !key.starts_with(|c: char| c.is_ascii_alphabetic()) {
        return None;
    }

    Some((key, value.trim()))
}

/// Reduce a key to its canonical form: lower case, unpadded, single-spaced.
fn normalise_key(key: &str) -> String {
    let stripped = key.trim().trim_end_matches(PADDING).trim();
    stripped
        .split_whitespace()
        .collect::<Vec<_>>()
        .join(" ")
        .to_lowercase()
}

/// Whether a value is a placeholder standing in for nothing.
fn is_null_value(value: &str) -> bool {
    let lowered = value.trim().to_lowercase();
    lowered.is_empty() || NULL_VALUES.contains(&lowered.as_str())
}

/// Route one field into the record.
fn assign(
    record: &mut WhoisRecord,
    extra: &mut BTreeMap<String, Vec<String>>,
    key: &str,
    value: &str,
) {
    if is_null_value(value) {
        return;
    }

    // Contact fields first: `registrant name` must not be mistaken for `name`.
    if let Some((role, field)) = split_contact_key(key) {
        let contact = match role {
            Role::Registrant => record.registrant.get_or_insert_with(Contact::default),
            Role::Admin => record.admin.get_or_insert_with(Contact::default),
            Role::Tech => record.tech.get_or_insert_with(Contact::default),
            Role::Billing => record.billing.get_or_insert_with(Contact::default),
        };
        if assign_contact(contact, field, value) {
            return;
        }
    }

    match key {
        "domain" | "domain name" | "domainname" | "ascii" | "idn" | "domain-name" => {
            if record.domain.is_none() {
                if let Ok(parsed) = DomainName::parse(value) {
                    record.domain = Some(parsed);
                }
            }
        }
        "registry domain id" | "domain id" | "roid" => {
            record.registry_id.get_or_insert_with(|| value.to_string());
        }

        "registrar" | "sponsoring registrar" | "registrar name" | "registrar organization" => {
            record.registrar.get_or_insert_with(|| value.to_string());
        }
        "registrar iana id" | "registrar iana identifier" | "iana id" => {
            record
                .registrar_iana_id
                .get_or_insert_with(|| value.to_string());
        }
        "registrar whois server" | "whois server" => {
            record
                .registrar_whois_server
                .get_or_insert_with(|| value.to_ascii_lowercase());
        }
        "registrar url" | "registrar website" | "url" | "website" => {
            record
                .registrar_url
                .get_or_insert_with(|| value.to_string());
        }
        "registrar abuse contact email" | "abuse contact email" | "abuse-mailbox" => {
            record
                .abuse_contact_email
                .get_or_insert_with(|| value.to_string());
        }
        "registrar abuse contact phone" | "abuse contact phone" => {
            record
                .abuse_contact_phone
                .get_or_insert_with(|| value.to_string());
        }

        "creation date"
        | "created"
        | "created on"
        | "created date"
        | "registered on"
        | "registration date"
        | "registration time"
        | "domain registration date"
        | "registered" => {
            set_date(&mut record.created, value);
        }
        "updated date" | "last updated" | "last update" | "last modified" | "changed"
        | "modified" | "last updated on" | "update date" => {
            set_date(&mut record.updated, value);
        }
        "registry expiry date"
        | "expiry date"
        | "expires"
        | "expires on"
        | "expiration date"
        | "expiration time"
        | "paid-till"
        | "paid till"
        | "renewal date"
        | "registrar registration expiration date"
        | "expire date" => {
            set_date(&mut record.expires, value);
        }

        "status"
        | "domain status"
        | "state"
        | "eppstatus"
        | "epp status"
        | "registration status" => {
            for status in split_status(value) {
                if !record.statuses.contains(&status) {
                    record.statuses.push(status);
                }
            }
        }
        "nserver" | "name server" | "nameserver" | "name servers" | "nameservers" | "ns"
        | "host name" => {
            for server in split_name_servers(value) {
                if !record.name_servers.contains(&server) {
                    record.name_servers.push(server);
                }
            }
        }
        "dnssec" | "dnssec status" | "signed" => {
            record.dnssec = Some(parse_dnssec(value));
        }

        // Unrecognised. Kept rather than dropped — a registry-specific field a caller
        // cares about lives here — unless it is really a paragraph of legal text.
        _ => {
            if PROSE_KEYS.contains(&key) {
                return;
            }
            if value.split_whitespace().count() > MAX_UNKNOWN_VALUE_WORDS {
                return;
            }

            extra
                .entry(key.to_string())
                .or_default()
                .push(value.to_string());
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Role {
    Registrant,
    Admin,
    Tech,
    Billing,
}

/// Split `registrant organization` into its role and its field.
fn split_contact_key(key: &str) -> Option<(Role, &str)> {
    const PREFIXES: [(&str, Role); 12] = [
        ("registrant contact", Role::Registrant),
        ("registrant", Role::Registrant),
        ("holder", Role::Registrant),
        ("owner", Role::Registrant),
        ("administrative contact", Role::Admin),
        ("admin contact", Role::Admin),
        ("admin", Role::Admin),
        ("technical contact", Role::Tech),
        ("tech contact", Role::Tech),
        ("tech", Role::Tech),
        ("billing contact", Role::Billing),
        ("billing", Role::Billing),
    ];

    for (prefix, role) in PREFIXES {
        if let Some(rest) = key.strip_prefix(prefix) {
            let field = rest.trim();
            // A bare `Registrant:` names the contact.
            return Some((role, if field.is_empty() { "name" } else { field }));
        }
    }

    None
}

/// Set one field of a contact. Returns whether the field was recognised.
///
/// First value wins throughout, so a registry that spells the same field two ways
/// cannot have its second spelling overwrite the first.
fn assign_contact(contact: &mut Contact, field: &str, value: &str) -> bool {
    let (slot, normalised): (&mut Option<String>, String) = match field {
        "name" | "person" => (&mut contact.name, value.to_string()),
        "handle" | "id" | "nic-hdl" => (&mut contact.handle, value.to_string()),
        "organization" | "organisation" | "org" | "company" => {
            (&mut contact.organization, value.to_string())
        }
        "city" => (&mut contact.city, value.to_string()),
        "state" | "province" | "state/province" | "state or province" => {
            (&mut contact.state, value.to_string())
        }
        "postal code" | "postalcode" | "zip" | "zip code" | "post code" => {
            (&mut contact.postal_code, value.to_string())
        }
        // ISO 3166 codes are conventionally upper case, and registries are not
        // consistent about it.
        "country" | "country code" | "countrycode" => (&mut contact.country, value.to_uppercase()),
        "phone" | "phone number" | "telephone" | "voice" => (&mut contact.phone, value.to_string()),
        "fax" | "fax number" | "facsimile" | "fax-no" => (&mut contact.fax, value.to_string()),
        "email" | "e-mail" | "email address" | "mail" => (&mut contact.email, value.to_string()),

        // An address spans several lines, so it accumulates rather than being set.
        "street" | "address" | "street address" | "address line 1" | "address line 2" => {
            contact.street.push(value.to_string());
            return true;
        }

        _ => return false,
    };

    if slot.is_none() {
        *slot = Some(normalised);
    }
    true
}

fn set_date(slot: &mut Option<chrono::DateTime<chrono::Utc>>, value: &str) {
    if slot.is_none() {
        if let Some(parsed) = parse_datetime(value) {
            *slot = Some(parsed);
        }
    }
}

/// Split a status value into individual statuses.
///
/// Registries put one per line, several comma-separated, or one with an
/// explanatory URL after it — `clientTransferProhibited
/// https://icann.org/epp#clientTransferProhibited`.
fn split_status(value: &str) -> Vec<String> {
    value
        .split(',')
        .flat_map(|part| part.split_whitespace().take(1))
        .filter(|part| !part.is_empty() && !part.starts_with("http"))
        .map(str::to_lowercase)
        .collect()
}

/// Split a name-server value into host names.
///
/// Several registries append the server's IP addresses on the same line, and a few
/// list all the servers space-separated.
fn split_name_servers(value: &str) -> Vec<String> {
    value
        .split([',', ' ', '\t'])
        .map(|part| part.trim().trim_end_matches('.').to_lowercase())
        .filter(|part| {
            // Keep host names, drop the IP addresses that follow them.
            part.contains('.')
                && !part.is_empty()
                && part.parse::<std::net::IpAddr>().is_err()
                && !part.starts_with('[')
        })
        .collect()
}

fn parse_dnssec(value: &str) -> bool {
    let lowered = value.trim().to_lowercase();
    matches!(
        lowered.as_str(),
        "signed" | "signeddelegation" | "signed delegation" | "yes" | "true" | "active" | "1"
    ) || lowered.starts_with("signed")
}

/// Final clean-up: sort what should be sorted, drop what turned out empty.
fn tidy(record: &mut WhoisRecord) {
    record.name_servers.sort();
    record.name_servers.dedup();
    record.statuses.dedup();

    for contact in [
        &mut record.registrant,
        &mut record.admin,
        &mut record.tech,
        &mut record.billing,
    ] {
        if contact.as_ref().is_some_and(Contact::is_empty) {
            *contact = None;
        }
    }
}

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

    fn parse(text: &str) -> WhoisRecord {
        KeyValueParser::new().parse_text(text)
    }

    #[test]
    fn reads_a_standard_gtld_record() {
        let record = parse(
            "\
Domain Name: EXAMPLE.COM
Registry Domain ID: 2336799_DOMAIN_COM-VRSN
Registrar WHOIS Server: whois.example-registrar.com
Registrar URL: http://www.example-registrar.com
Updated Date: 2026-08-14T07:01:44Z
Creation Date: 1995-08-14T04:00:00Z
Registry Expiry Date: 2027-08-13T04:00:00Z
Registrar: Example Registrar, LLC
Registrar IANA ID: 376
Registrar Abuse Contact Email: abuse@example-registrar.com
Domain Status: clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
Name Server: A.IANA-SERVERS.NET
Name Server: B.IANA-SERVERS.NET
DNSSEC: signedDelegation
",
        );

        assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.com");
        assert_eq!(record.registrar.as_deref(), Some("Example Registrar, LLC"));
        assert_eq!(record.registrar_iana_id.as_deref(), Some("376"));
        assert_eq!(
            record.registrar_whois_server.as_deref(),
            Some("whois.example-registrar.com")
        );
        assert_eq!(
            record.registry_id.as_deref(),
            Some("2336799_DOMAIN_COM-VRSN")
        );
        assert_eq!(
            record.abuse_contact_email.as_deref(),
            Some("abuse@example-registrar.com")
        );
        assert_eq!(record.dnssec, Some(true));
        assert_eq!(
            record.name_servers,
            ["a.iana-servers.net", "b.iana-servers.net"]
        );
        assert_eq!(
            record.statuses,
            ["clientdeleteprohibited", "clienttransferprohibited"]
        );
        assert!(record.is_transfer_locked());
        assert_eq!(
            record.created.unwrap().format("%Y-%m-%d").to_string(),
            "1995-08-14"
        );
        assert_eq!(
            record.expires.unwrap().format("%Y-%m-%d").to_string(),
            "2027-08-13"
        );
    }

    #[test]
    fn reads_nominets_value_on_the_next_line_layout() {
        let record = parse(
            "\
    Domain name:
        example.co.uk

    Registrar:
        Example Registrar Ltd [Tag = EXAMPLE]

    Relevant dates:
        Registered on: 01-Jan-2001
        Expiry date:  01-Jan-2027

    Name servers:
        ns1.example.co.uk
        ns2.example.co.uk
",
        );

        assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.co.uk");
        assert_eq!(
            record.registrar.as_deref(),
            Some("Example Registrar Ltd [Tag = EXAMPLE]")
        );
        assert_eq!(record.created.unwrap().format("%Y").to_string(), "2001");
        assert_eq!(record.expires.unwrap().format("%Y").to_string(), "2027");
        assert_eq!(
            record.name_servers,
            ["ns1.example.co.uk", "ns2.example.co.uk"]
        );
    }

    #[test]
    fn a_nested_field_is_not_swallowed_by_its_header() {
        // `Relevant dates:` has no value, but the lines under it are fields of
        // their own and must not become its value.
        let record = parse("Relevant dates:\n    Registered on: 01-Jan-2001\n");
        assert!(record.created.is_some());
        assert!(record.extra_field("relevant dates").is_none());
    }

    #[test]
    fn reads_padded_keys() {
        let record = parse(
            "\
domain.............: example.fi
status.............: Registered
created............: 1.1.2001 00:00:00
nserver............: ns1.example.fi
",
        );

        assert_eq!(record.domain.as_ref().unwrap().as_ascii(), "example.fi");
        assert_eq!(record.statuses, ["registered"]);
        assert_eq!(record.name_servers, ["ns1.example.fi"]);
        assert!(record.created.is_some());
    }

    #[test]
    fn reads_contacts_by_role() {
        let record = parse(
            "\
Registrant Name: Ada Lovelace
Registrant Organization: Example Ltd
Registrant Street: 1 Example Street
Registrant City: London
Registrant Postal Code: EC1A 1AA
Registrant Country: gb
Registrant Email: ada@example.com
Admin Name: REDACTED FOR PRIVACY
Tech Email: tech@example.com
Billing Phone: +44.2000000000
",
        );

        let registrant = record.registrant.as_ref().unwrap();
        assert_eq!(registrant.name.as_deref(), Some("Ada Lovelace"));
        assert_eq!(registrant.organization.as_deref(), Some("Example Ltd"));
        assert_eq!(registrant.street, ["1 Example Street"]);
        assert_eq!(registrant.city.as_deref(), Some("London"));
        assert_eq!(
            registrant.country.as_deref(),
            Some("GB"),
            "country is upper-cased"
        );
        assert!(!registrant.is_redacted());

        assert!(record.admin.as_ref().unwrap().is_redacted());
        assert_eq!(
            record.tech.as_ref().unwrap().email.as_deref(),
            Some("tech@example.com")
        );
        assert_eq!(
            record.billing.as_ref().unwrap().phone.as_deref(),
            Some("+44.2000000000")
        );
    }

    #[test]
    fn a_bare_role_key_names_the_contact() {
        let record = parse("Registrant: Ada Lovelace\nHolder: Ignored Second\n");
        let registrant = record.registrant.as_ref().unwrap();
        // First value wins, so a second spelling of the same field cannot overwrite.
        assert_eq!(registrant.name.as_deref(), Some("Ada Lovelace"));
    }

    #[test]
    fn a_contact_with_nothing_in_it_is_dropped() {
        let record = parse("Registrant Fax: n/a\nDomain Name: example.com\n");
        assert!(record.registrant.is_none());
    }

    #[test]
    fn urls_are_values_not_fields() {
        let record = parse("Registrar URL: https://www.example.com/whois\n");
        assert_eq!(
            record.registrar_url.as_deref(),
            Some("https://www.example.com/whois")
        );
        assert!(record.extra.is_empty(), "{:?}", record.extra);
    }

    #[test]
    fn a_legal_notice_is_not_a_record() {
        // Verisign's availability response for an unregistered `.com`. Every line here
        // is prose; parsing any of it as a field made a free domain look registered.
        let record = parse(
            "\
No match for \"NOTHERE.COM\".
>>> Last update of whois database: 2026-08-05T12:00:00Z <<<

NOTICE: The expiration date displayed in this record is the date the
registrar's sponsorship of the domain name registration in the registry is
currently set to expire.

TERMS OF USE: You are not authorized to access or query our WHOIS database
through the use of high-volume, automated, electronic processes.
",
        );

        assert!(
            record.is_empty(),
            "a disclaimer parsed into a record: {record:?}"
        );
        assert!(record.extra.is_empty(), "{:?}", record.extra);
    }

    #[test]
    fn an_unknown_key_with_a_sentence_after_it_is_prose() {
        // A short unknown value is a field worth keeping.
        let kept = parse("Eligibility Type: Company\n");
        assert_eq!(kept.extra_field("eligibility type"), Some("Company"));

        // A long one is a sentence.
        let dropped = parse(
            "Something Unknown: this is a full sentence of explanatory text that \
             goes on for a while and is plainly not a field value\n",
        );
        assert!(dropped.extra.is_empty(), "{:?}", dropped.extra);
    }

    #[test]
    fn prose_with_a_colon_is_not_parsed_as_a_field() {
        let record = parse(
            "NOTICE: The expiration date displayed in this record is the date the\n\
             registrar's sponsorship of the domain name registration in the registry\n\
             is currently set to expire: this date does not necessarily reflect much.\n",
        );
        // "NOTICE" is a plausible key, so it lands in `extra`; the long clause must
        // not, or every sentence in a disclaimer becomes a field.
        assert!(
            record.extra.len() <= 1,
            "prose was parsed as fields: {:?}",
            record.extra
        );
        assert!(record.expires.is_none());
    }

    #[test]
    fn placeholder_values_are_treated_as_absent() {
        let record = parse(
            "\
Domain Name: example.com
Registrar: n/a
Registrant Name: NOT AVAILABLE
Expiry Date: unknown
",
        );

        assert!(record.registrar.is_none());
        assert!(record.registrant.is_none());
        assert!(record.expires.is_none());
        assert!(record.domain.is_some());
    }

    #[test]
    fn name_servers_drop_their_trailing_ip_addresses() {
        let record = parse("nserver: ns1.example.ru 192.0.2.1\nnserver: ns2.example.ru.\n");
        assert_eq!(record.name_servers, ["ns1.example.ru", "ns2.example.ru"]);
    }

    #[test]
    fn duplicate_name_servers_and_statuses_collapse() {
        let record = parse(
            "\
Name Server: NS1.EXAMPLE.COM
Name Server: ns1.example.com
Status: ok
Status: OK
",
        );
        assert_eq!(record.name_servers, ["ns1.example.com"]);
        assert_eq!(record.statuses, ["ok"]);
    }

    #[test]
    fn dnssec_is_read_in_the_forms_registries_use() {
        for (value, expected) in [
            ("signedDelegation", true),
            ("signed", true),
            ("yes", true),
            ("unsigned", false),
            ("no", false),
        ] {
            let record = parse(&format!("DNSSEC: {value}\n"));
            assert_eq!(record.dnssec, Some(expected), "for {value:?}");
        }
    }

    #[test]
    fn unrecognised_fields_are_kept() {
        let record = parse("Eligibility Type: Company\nEligibility Name: Example Pty Ltd\n");
        assert_eq!(record.extra_field("eligibility type"), Some("Company"));
        assert_eq!(
            record.extra_field("Eligibility Name"),
            Some("Example Pty Ltd")
        );
    }

    #[test]
    fn banners_are_skipped() {
        let record = parse(
            "\
% This is a banner
# Another
>>> Last update <<<
-- separator
Domain Name: example.com
",
        );
        assert!(record.extra.is_empty(), "{:?}", record.extra);
        assert!(record.domain.is_some());
    }

    #[test]
    fn a_no_match_response_parses_to_nothing() {
        let record = parse("No match for \"NOTHERE.COM\".\n");
        assert!(record.is_empty(), "{record:?}");
    }

    #[test]
    fn an_empty_response_parses_to_nothing() {
        assert!(parse("").is_empty());
        assert!(parse("   \n\n  ").is_empty());
    }

    #[test]
    fn punycode_and_unicode_domains_both_land_in_ascii_form() {
        let ace = parse("Domain Name: XN--MNCHEN-3YA.DE\n");
        let unicode = parse("Domain: münchen.de\n");
        assert_eq!(ace.domain.as_ref().unwrap().as_ascii(), "xn--mnchen-3ya.de");
        assert_eq!(unicode.domain, ace.domain);
    }
}