Skip to main content

seer_core/output/
human.rs

1use chrono::TimeDelta;
2use colored::Colorize;
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use super::OutputFormatter;
7use crate::caa::{CaaPolicy, IssuerCaaMatch};
8use crate::colors::CatppuccinExt;
9use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
10use crate::lookup::LookupResult;
11use crate::rdap::RdapResponse;
12use crate::status::StatusResponse;
13use crate::whois::WhoisResponse;
14
15/// Strips ANSI escape sequences from untrusted external strings to prevent
16/// terminal injection via malicious WHOIS/RDAP response data.
17static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
18    Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[A-Z@-_]")
19        .expect("Invalid ANSI escape regex")
20});
21
22fn sanitize_display(s: &str) -> String {
23    ANSI_ESCAPE_RE.replace_all(s, "").to_string()
24}
25
26fn format_duration(duration: TimeDelta) -> String {
27    let total_secs = duration.num_seconds();
28    if total_secs < 60 {
29        format!("{}s", total_secs)
30    } else if total_secs < 3600 {
31        let mins = total_secs / 60;
32        let secs = total_secs % 60;
33        format!("{}m {}s", mins, secs)
34    } else {
35        let hours = total_secs / 3600;
36        let mins = (total_secs % 3600) / 60;
37        format!("{}h {}m", hours, mins)
38    }
39}
40
41pub struct HumanFormatter {
42    use_colors: bool,
43}
44
45impl Default for HumanFormatter {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51impl HumanFormatter {
52    pub fn new() -> Self {
53        Self { use_colors: true }
54    }
55
56    pub fn without_colors(mut self) -> Self {
57        self.use_colors = false;
58        self
59    }
60
61    fn label(&self, text: &str) -> String {
62        if self.use_colors {
63            text.sky().bold().to_string()
64        } else {
65            text.to_string()
66        }
67    }
68
69    fn value(&self, text: &str) -> String {
70        if self.use_colors {
71            text.ctp_white().to_string()
72        } else {
73            text.to_string()
74        }
75    }
76
77    fn success(&self, text: &str) -> String {
78        if self.use_colors {
79            text.ctp_green().bold().to_string()
80        } else {
81            text.to_string()
82        }
83    }
84
85    fn warning(&self, text: &str) -> String {
86        if self.use_colors {
87            text.ctp_yellow().bold().to_string()
88        } else {
89            text.to_string()
90        }
91    }
92
93    fn error(&self, text: &str) -> String {
94        if self.use_colors {
95            text.ctp_red().bold().to_string()
96        } else {
97            text.to_string()
98        }
99    }
100
101    fn dim(&self, text: &str) -> String {
102        if self.use_colors {
103            text.overlay1().to_string()
104        } else {
105            text.to_string()
106        }
107    }
108
109    fn header(&self, text: &str) -> String {
110        if self.use_colors {
111            format!(
112                "\n{}\n{}",
113                text.lavender().bold(),
114                "─".repeat(text.len()).subtext0()
115            )
116        } else {
117            format!("\n{}\n{}", text, "-".repeat(text.len()))
118        }
119    }
120
121    /// Renders the CAA policy block (records, issuer match, note) shared
122    /// between `format_status` and `format_ssl`. `indent` is the leading
123    /// whitespace per line — typically `"  "`.
124    fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
125        let mut out = Vec::new();
126        out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
127
128        if !caa.has_policy {
129            out.push(format!(
130                "{}  {}",
131                indent,
132                self.value("No CAA records (any CA may issue)")
133            ));
134        } else {
135            if let Some(ref eff) = caa.effective_domain {
136                out.push(format!(
137                    "{}  {}: {}",
138                    indent,
139                    self.label("Found at"),
140                    self.value(&sanitize_display(eff))
141                ));
142            }
143            for r in &caa.records {
144                out.push(format!(
145                    "{}  {} {} \"{}\"",
146                    indent,
147                    self.value(&r.flags.to_string()),
148                    self.label(&r.tag),
149                    sanitize_display(&r.value)
150                ));
151            }
152        }
153
154        if let Some(m) = caa.issuer_match {
155            let rendered = match m {
156                IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
157                IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
158                IssuerCaaMatch::Mismatch => self
159                    .warning("issuer not in current CAA policy (informational — see note below)"),
160                IssuerCaaMatch::Indeterminate => {
161                    self.warning("CAA present but no issue/issuewild tags")
162                }
163            };
164            out.push(format!(
165                "{}  {}: {}",
166                indent,
167                self.label("Issuer vs CAA"),
168                rendered
169            ));
170        }
171
172        // Note is appended separately by the caller so it can sit at the
173        // very bottom of the overall output, un-indented.
174        out
175    }
176
177    /// Appends the trailing CAA note as the very last lines of an output
178    /// buffer: a blank separator line followed by `note: …` with no
179    /// indentation, so the explanation reads as a footer to the whole
180    /// report rather than part of the CAA block.
181    fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
182        out.push(String::new());
183        out.push(format!("note: {}", caa.note));
184    }
185
186    /// Formats an expiration date with a human-readable status suffix.
187    ///
188    /// Behaviour:
189    /// - already expired (negative days): red "expired N days ago"
190    /// - <30 days remaining: red "expires in N days!"
191    /// - <90 days remaining: yellow "expires in N days"
192    /// - otherwise: green "expires in N days"
193    fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
194        if days_until < 0 {
195            self.error(&format!(
196                "{} (expired {} days ago)",
197                expiry_str, -days_until
198            ))
199        } else if days_until < 30 {
200            self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
201        } else if days_until < 90 {
202            self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
203        } else {
204            self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
205        }
206    }
207}
208
209impl OutputFormatter for HumanFormatter {
210    fn format_whois(&self, response: &WhoisResponse) -> String {
211        let mut output = Vec::new();
212
213        output.push(self.header(&format!("WHOIS: {}", sanitize_display(&response.domain))));
214
215        if response.is_available() {
216            output.push(format!("  {} Domain is available", self.success("✓")));
217            return output.join("\n");
218        }
219
220        if let Some(ref registrar) = response.registrar {
221            output.push(format!(
222                "  {}: {}",
223                self.label("Registrar"),
224                self.value(&sanitize_display(registrar))
225            ));
226        }
227
228        if let Some(ref registrant) = response.registrant {
229            output.push(format!(
230                "  {}: {}",
231                self.label("Registrant"),
232                self.value(&sanitize_display(registrant))
233            ));
234        }
235
236        if let Some(ref organization) = response.organization {
237            output.push(format!(
238                "  {}: {}",
239                self.label("Organization"),
240                self.value(&sanitize_display(organization))
241            ));
242        }
243
244        // Registrant contact details
245        let has_registrant_details = response.registrant_email.is_some()
246            || response.registrant_phone.is_some()
247            || response.registrant_address.is_some()
248            || response.registrant_country.is_some();
249
250        if has_registrant_details {
251            output.push(format!("\n  {}:", self.label("Registrant Contact")));
252            if let Some(ref email) = response.registrant_email {
253                output.push(format!(
254                    "    {}: {}",
255                    self.label("Email"),
256                    self.value(&sanitize_display(email))
257                ));
258            }
259            if let Some(ref phone) = response.registrant_phone {
260                output.push(format!(
261                    "    {}: {}",
262                    self.label("Phone"),
263                    self.value(&sanitize_display(phone))
264                ));
265            }
266            if let Some(ref address) = response.registrant_address {
267                output.push(format!(
268                    "    {}: {}",
269                    self.label("Address"),
270                    self.value(&sanitize_display(address))
271                ));
272            }
273            if let Some(ref country) = response.registrant_country {
274                output.push(format!(
275                    "    {}: {}",
276                    self.label("Country"),
277                    self.value(&sanitize_display(country))
278                ));
279            }
280        }
281
282        // Admin contact
283        let has_admin_contact = response.admin_name.is_some()
284            || response.admin_organization.is_some()
285            || response.admin_email.is_some()
286            || response.admin_phone.is_some();
287
288        if has_admin_contact {
289            output.push(format!("\n  {}:", self.label("Admin Contact")));
290            if let Some(ref name) = response.admin_name {
291                output.push(format!(
292                    "    {}: {}",
293                    self.label("Name"),
294                    self.value(&sanitize_display(name))
295                ));
296            }
297            if let Some(ref org) = response.admin_organization {
298                output.push(format!(
299                    "    {}: {}",
300                    self.label("Organization"),
301                    self.value(&sanitize_display(org))
302                ));
303            }
304            if let Some(ref email) = response.admin_email {
305                output.push(format!(
306                    "    {}: {}",
307                    self.label("Email"),
308                    self.value(&sanitize_display(email))
309                ));
310            }
311            if let Some(ref phone) = response.admin_phone {
312                output.push(format!(
313                    "    {}: {}",
314                    self.label("Phone"),
315                    self.value(&sanitize_display(phone))
316                ));
317            }
318        }
319
320        // Tech contact
321        let has_tech_contact = response.tech_name.is_some()
322            || response.tech_organization.is_some()
323            || response.tech_email.is_some()
324            || response.tech_phone.is_some();
325
326        if has_tech_contact {
327            output.push(format!("\n  {}:", self.label("Tech Contact")));
328            if let Some(ref name) = response.tech_name {
329                output.push(format!(
330                    "    {}: {}",
331                    self.label("Name"),
332                    self.value(&sanitize_display(name))
333                ));
334            }
335            if let Some(ref org) = response.tech_organization {
336                output.push(format!(
337                    "    {}: {}",
338                    self.label("Organization"),
339                    self.value(&sanitize_display(org))
340                ));
341            }
342            if let Some(ref email) = response.tech_email {
343                output.push(format!(
344                    "    {}: {}",
345                    self.label("Email"),
346                    self.value(&sanitize_display(email))
347                ));
348            }
349            if let Some(ref phone) = response.tech_phone {
350                output.push(format!(
351                    "    {}: {}",
352                    self.label("Phone"),
353                    self.value(&sanitize_display(phone))
354                ));
355            }
356        }
357
358        if let Some(created) = response.creation_date {
359            output.push(format!(
360                "  {}: {}",
361                self.label("Created"),
362                self.value(&created.format("%Y-%m-%d").to_string())
363            ));
364        }
365
366        if let Some(expires) = response.expiration_date {
367            let days_until = (expires - chrono::Utc::now()).num_days();
368            let expiry_str = expires.format("%Y-%m-%d").to_string();
369            let status = self.format_expiry_status(&expiry_str, days_until);
370            output.push(format!("  {}: {}", self.label("Expires"), status));
371        }
372
373        if let Some(updated) = response.updated_date {
374            output.push(format!(
375                "  {}: {}",
376                self.label("Updated"),
377                self.value(&updated.format("%Y-%m-%d").to_string())
378            ));
379        }
380
381        if !response.nameservers.is_empty() {
382            output.push(format!("  {}:", self.label("Nameservers")));
383            for ns in &response.nameservers {
384                output.push(format!("    - {}", self.value(&sanitize_display(ns))));
385            }
386        }
387
388        if !response.status.is_empty() {
389            output.push(format!("  {}:", self.label("Status")));
390            for status in &response.status {
391                output.push(format!("    - {}", self.value(&sanitize_display(status))));
392            }
393        }
394
395        if let Some(ref dnssec) = response.dnssec {
396            output.push(format!(
397                "  {}: {}",
398                self.label("DNSSEC"),
399                self.value(&sanitize_display(dnssec))
400            ));
401        }
402
403        output.push(format!(
404            "  {}: {}",
405            self.label("WHOIS Server"),
406            self.value(&sanitize_display(&response.whois_server))
407        ));
408
409        output.join("\n")
410    }
411
412    fn format_rdap(&self, response: &RdapResponse) -> String {
413        let mut output = Vec::new();
414
415        let name = response
416            .domain_name()
417            .or(response.name.as_deref())
418            .unwrap_or("Unknown");
419        output.push(self.header(&format!("RDAP: {}", sanitize_display(name))));
420
421        if let Some(handle) = &response.handle {
422            output.push(format!(
423                "  {}: {}",
424                self.label("Handle"),
425                self.value(&sanitize_display(handle))
426            ));
427        }
428
429        if let Some(registrar) = response.get_registrar() {
430            output.push(format!(
431                "  {}: {}",
432                self.label("Registrar"),
433                self.value(&sanitize_display(&registrar))
434            ));
435        }
436
437        if let Some(registrant) = response.get_registrant() {
438            output.push(format!(
439                "  {}: {}",
440                self.label("Registrant"),
441                self.value(&sanitize_display(&registrant))
442            ));
443        }
444
445        if let Some(organization) = response.get_registrant_organization() {
446            output.push(format!(
447                "  {}: {}",
448                self.label("Organization"),
449                self.value(&sanitize_display(&organization))
450            ));
451        }
452
453        // Registrant contact details
454        if let Some(contact) = response.get_registrant_contact() {
455            if contact.has_info() {
456                output.push(format!("\n  {}:", self.label("Registrant Contact")));
457                if let Some(ref email) = contact.email {
458                    output.push(format!(
459                        "    {}: {}",
460                        self.label("Email"),
461                        self.value(&sanitize_display(email))
462                    ));
463                }
464                if let Some(ref phone) = contact.phone {
465                    output.push(format!(
466                        "    {}: {}",
467                        self.label("Phone"),
468                        self.value(&sanitize_display(phone))
469                    ));
470                }
471                if let Some(ref address) = contact.address {
472                    output.push(format!(
473                        "    {}: {}",
474                        self.label("Address"),
475                        self.value(&sanitize_display(address))
476                    ));
477                }
478                if let Some(ref country) = contact.country {
479                    output.push(format!(
480                        "    {}: {}",
481                        self.label("Country"),
482                        self.value(&sanitize_display(country))
483                    ));
484                }
485            }
486        }
487
488        // Admin contact
489        if let Some(contact) = response.get_admin_contact() {
490            if contact.has_info() {
491                output.push(format!("\n  {}:", self.label("Admin Contact")));
492                if let Some(ref name) = contact.name {
493                    output.push(format!(
494                        "    {}: {}",
495                        self.label("Name"),
496                        self.value(&sanitize_display(name))
497                    ));
498                }
499                if let Some(ref org) = contact.organization {
500                    output.push(format!(
501                        "    {}: {}",
502                        self.label("Organization"),
503                        self.value(&sanitize_display(org))
504                    ));
505                }
506                if let Some(ref email) = contact.email {
507                    output.push(format!(
508                        "    {}: {}",
509                        self.label("Email"),
510                        self.value(&sanitize_display(email))
511                    ));
512                }
513                if let Some(ref phone) = contact.phone {
514                    output.push(format!(
515                        "    {}: {}",
516                        self.label("Phone"),
517                        self.value(&sanitize_display(phone))
518                    ));
519                }
520                if let Some(ref address) = contact.address {
521                    output.push(format!(
522                        "    {}: {}",
523                        self.label("Address"),
524                        self.value(&sanitize_display(address))
525                    ));
526                }
527                if let Some(ref country) = contact.country {
528                    output.push(format!(
529                        "    {}: {}",
530                        self.label("Country"),
531                        self.value(&sanitize_display(country))
532                    ));
533                }
534            }
535        }
536
537        // Tech contact
538        if let Some(contact) = response.get_tech_contact() {
539            if contact.has_info() {
540                output.push(format!("\n  {}:", self.label("Tech Contact")));
541                if let Some(ref name) = contact.name {
542                    output.push(format!(
543                        "    {}: {}",
544                        self.label("Name"),
545                        self.value(&sanitize_display(name))
546                    ));
547                }
548                if let Some(ref org) = contact.organization {
549                    output.push(format!(
550                        "    {}: {}",
551                        self.label("Organization"),
552                        self.value(&sanitize_display(org))
553                    ));
554                }
555                if let Some(ref email) = contact.email {
556                    output.push(format!(
557                        "    {}: {}",
558                        self.label("Email"),
559                        self.value(&sanitize_display(email))
560                    ));
561                }
562                if let Some(ref phone) = contact.phone {
563                    output.push(format!(
564                        "    {}: {}",
565                        self.label("Phone"),
566                        self.value(&sanitize_display(phone))
567                    ));
568                }
569                if let Some(ref address) = contact.address {
570                    output.push(format!(
571                        "    {}: {}",
572                        self.label("Address"),
573                        self.value(&sanitize_display(address))
574                    ));
575                }
576                if let Some(ref country) = contact.country {
577                    output.push(format!(
578                        "    {}: {}",
579                        self.label("Country"),
580                        self.value(&sanitize_display(country))
581                    ));
582                }
583            }
584        }
585
586        // Billing contact
587        if let Some(contact) = response.get_billing_contact() {
588            if contact.has_info() {
589                output.push(format!("\n  {}:", self.label("Billing Contact")));
590                if let Some(ref name) = contact.name {
591                    output.push(format!(
592                        "    {}: {}",
593                        self.label("Name"),
594                        self.value(&sanitize_display(name))
595                    ));
596                }
597                if let Some(ref org) = contact.organization {
598                    output.push(format!(
599                        "    {}: {}",
600                        self.label("Organization"),
601                        self.value(&sanitize_display(org))
602                    ));
603                }
604                if let Some(ref email) = contact.email {
605                    output.push(format!(
606                        "    {}: {}",
607                        self.label("Email"),
608                        self.value(&sanitize_display(email))
609                    ));
610                }
611                if let Some(ref phone) = contact.phone {
612                    output.push(format!(
613                        "    {}: {}",
614                        self.label("Phone"),
615                        self.value(&sanitize_display(phone))
616                    ));
617                }
618                if let Some(ref address) = contact.address {
619                    output.push(format!(
620                        "    {}: {}",
621                        self.label("Address"),
622                        self.value(&sanitize_display(address))
623                    ));
624                }
625                if let Some(ref country) = contact.country {
626                    output.push(format!(
627                        "    {}: {}",
628                        self.label("Country"),
629                        self.value(&sanitize_display(country))
630                    ));
631                }
632            }
633        }
634
635        if let Some(created) = response.creation_date() {
636            output.push(format!(
637                "  {}: {}",
638                self.label("Created"),
639                self.value(&created.format("%Y-%m-%d").to_string())
640            ));
641        }
642
643        if let Some(expires) = response.expiration_date() {
644            let days_until = (expires - chrono::Utc::now()).num_days();
645            let expiry_str = expires.format("%Y-%m-%d").to_string();
646            let status = self.format_expiry_status(&expiry_str, days_until);
647            output.push(format!("  {}: {}", self.label("Expires"), status));
648        }
649
650        if let Some(updated) = response.last_updated() {
651            output.push(format!(
652                "  {}: {}",
653                self.label("Updated"),
654                self.value(&updated.format("%Y-%m-%d").to_string())
655            ));
656        }
657
658        if !response.status.is_empty() {
659            output.push(format!("  {}:", self.label("Status")));
660            for status in &response.status {
661                output.push(format!("    - {}", self.value(&sanitize_display(status))));
662            }
663        }
664
665        let nameservers = response.nameserver_names();
666        if !nameservers.is_empty() {
667            output.push(format!("  {}:", self.label("Nameservers")));
668            for ns in &nameservers {
669                output.push(format!("    - {}", self.value(&sanitize_display(ns))));
670            }
671        }
672
673        if response.is_dnssec_signed() {
674            output.push(format!(
675                "  {}: {}",
676                self.label("DNSSEC"),
677                self.success("signed")
678            ));
679        }
680
681        // IP-specific fields
682        if let Some(ref start) = response.start_address {
683            output.push(format!(
684                "  {}: {}",
685                self.label("Start Address"),
686                self.value(&sanitize_display(start))
687            ));
688        }
689
690        if let Some(ref end) = response.end_address {
691            output.push(format!(
692                "  {}: {}",
693                self.label("End Address"),
694                self.value(&sanitize_display(end))
695            ));
696        }
697
698        if let Some(ref country) = response.country {
699            output.push(format!(
700                "  {}: {}",
701                self.label("Country"),
702                self.value(&sanitize_display(country))
703            ));
704        }
705
706        // ASN-specific fields
707        if let Some(start) = response.start_autnum {
708            output.push(format!(
709                "  {}: {}",
710                self.label("AS Number"),
711                self.value(&format!(
712                    "AS{} - AS{}",
713                    start,
714                    response.end_autnum.unwrap_or(start)
715                ))
716            ));
717        }
718
719        output.join("\n")
720    }
721
722    fn format_dns(&self, records: &[DnsRecord]) -> String {
723        let mut output = Vec::new();
724
725        if records.is_empty() {
726            output.push(self.warning("No records found"));
727            // DNSSEC disclaimer applies whether or not records were returned.
728            output.push(String::new());
729            output.push(self.warning("Note: DNS responses are not DNSSEC-validated"));
730            return output.join("\n");
731        }
732
733        let domain = &records[0].name;
734        let record_type = &records[0].record_type;
735        output.push(self.header(&format!(
736            "DNS {} Records: {}",
737            record_type,
738            sanitize_display(domain)
739        )));
740
741        for record in records {
742            output.push(format!(
743                "  {} {} {} {}",
744                self.value(&sanitize_display(&record.name)),
745                self.label(&format!("{}", record.ttl)),
746                self.label(&format!("{}", record.record_type)),
747                self.success(&sanitize_display(&record.data.to_string()))
748            ));
749        }
750
751        // DNSSEC disclosure (M12): Seer's resolver does not validate DNSSEC,
752        // and UDP DNS is trivially spoofable. Surface this once per DNS block.
753        output.push(String::new());
754        output.push(self.warning("Note: DNS responses are not DNSSEC-validated"));
755
756        output.join("\n")
757    }
758
759    fn format_propagation(&self, result: &PropagationResult) -> String {
760        let mut output = Vec::new();
761
762        output.push(self.header(&format!(
763            "Propagation Check: {} {}",
764            result.domain, result.record_type
765        )));
766
767        // Summary
768        let percentage = result.propagation_percentage;
769        let percentage_str = format!("{:.1}%", percentage);
770        let status = if percentage >= 100.0 {
771            self.success(&format!("✓ Fully propagated ({})", percentage_str))
772        } else if percentage >= 80.0 {
773            self.warning(&format!("◐ Mostly propagated ({})", percentage_str))
774        } else if percentage >= 50.0 {
775            self.warning(&format!("◑ Partially propagated ({})", percentage_str))
776        } else {
777            self.error(&format!("✗ Not propagated ({})", percentage_str))
778        };
779        output.push(format!("  {}", status));
780
781        output.push(format!(
782            "  {}: {}/{}",
783            self.label("Servers responding"),
784            result.servers_responding,
785            result.servers_checked
786        ));
787
788        // Consensus values
789        if !result.consensus_values.is_empty() {
790            output.push(format!("  {}:", self.label("Consensus values")));
791            for value in &result.consensus_values {
792                output.push(format!("    - {}", self.success(&sanitize_display(value))));
793            }
794        }
795
796        // Inconsistencies (genuine answer conflicts only)
797        if !result.inconsistencies.is_empty() {
798            output.push(format!("  {}:", self.label("Inconsistencies")));
799            for inconsistency in &result.inconsistencies {
800                output.push(format!(
801                    "    - {}",
802                    self.warning(&sanitize_display(inconsistency))
803                ));
804            }
805        }
806
807        // Unreachable servers (timeouts, network errors) — distinct from
808        // answer conflicts. Reporting these separately prevents a single
809        // timeout from being misread as divergent DNS state.
810        if !result.unreachable_servers.is_empty() {
811            output.push(format!("  {}:", self.label("Unreachable servers")));
812            for unreachable in &result.unreachable_servers {
813                let error_msg = unreachable.error.as_deref().unwrap_or("no response");
814                output.push(format!(
815                    "    - {} ({}): {}",
816                    self.warning(&sanitize_display(&unreachable.name)),
817                    sanitize_display(&unreachable.ip),
818                    sanitize_display(error_msg),
819                ));
820            }
821        }
822
823        // Group results by region
824        let mut by_region: std::collections::HashMap<&str, Vec<_>> =
825            std::collections::HashMap::new();
826        for server_result in &result.results {
827            by_region
828                .entry(server_result.server.location.as_str())
829                .or_default()
830                .push(server_result);
831        }
832
833        // Sort regions for consistent output
834        let mut regions: Vec<_> = by_region.keys().cloned().collect();
835        regions.sort();
836
837        output.push(format!("\n  {}:", self.label("Results by Region")));
838        for region in &regions {
839            output.push(format!("\n    {}:", self.label(region)));
840            if let Some(server_results) = by_region.get(region) {
841                for server_result in server_results {
842                    let status_icon = if server_result.success { "✓" } else { "✗" };
843                    let status_colored = if server_result.success {
844                        self.success(status_icon)
845                    } else {
846                        self.error(status_icon)
847                    };
848
849                    let values = if server_result.success {
850                        if server_result.records.is_empty() {
851                            "NXDOMAIN".to_string()
852                        } else {
853                            server_result
854                                .records
855                                .iter()
856                                .map(|r| sanitize_display(&r.format_short()))
857                                .collect::<Vec<_>>()
858                                .join(", ")
859                        }
860                    } else {
861                        sanitize_display(server_result.error.as_deref().unwrap_or("Error"))
862                    };
863
864                    output.push(format!(
865                        "      {} {} ({}) - {} [{}ms]",
866                        status_colored,
867                        self.value(&server_result.server.name),
868                        server_result.server.ip,
869                        values,
870                        server_result.response_time_ms
871                    ));
872                }
873            }
874        }
875
876        // DNSSEC disclosure (M12). The resolver does not perform DNSSEC
877        // validation and UDP DNS is trivially spoofable — surface this so
878        // users don't treat the results as authenticated.
879        if !result.dnssec_validated {
880            output.push(String::new());
881            output.push(self.warning("Note: DNS responses are not DNSSEC-validated"));
882        }
883
884        output.join("\n")
885    }
886
887    fn format_lookup(&self, result: &LookupResult) -> String {
888        let mut output = Vec::new();
889
890        let domain = result
891            .domain_name()
892            .unwrap_or_else(|| "Unknown".to_string());
893        let header_suffix = match result {
894            LookupResult::Rdap { .. } => "via RDAP".to_string(),
895            LookupResult::Whois { .. } => "via WHOIS".to_string(),
896            LookupResult::Available { data, .. } => match data.verdict() {
897                "available" => "available".to_string(),
898                "likely_available" => "likely available".to_string(),
899                "registered" => "registered".to_string(),
900                "likely_registered" => "likely registered".to_string(),
901                _ => "status unknown".to_string(),
902            },
903        };
904
905        output.push(self.header(&format!(
906            "Lookup: {} ({})",
907            sanitize_display(&domain),
908            header_suffix
909        )));
910
911        match result {
912            LookupResult::Rdap {
913                data,
914                whois_fallback,
915            } => {
916                output.push(format!(
917                    "  {}: {}",
918                    self.label("Source"),
919                    self.success("RDAP (modern protocol)")
920                ));
921
922                if let Some(registrar) = data.get_registrar() {
923                    output.push(format!(
924                        "  {}: {}",
925                        self.label("Registrar"),
926                        self.value(&sanitize_display(&registrar))
927                    ));
928                }
929
930                if let Some(registrant) = data.get_registrant() {
931                    output.push(format!(
932                        "  {}: {}",
933                        self.label("Registrant"),
934                        self.value(&sanitize_display(&registrant))
935                    ));
936                }
937
938                if let Some(organization) = data.get_registrant_organization() {
939                    output.push(format!(
940                        "  {}: {}",
941                        self.label("Organization"),
942                        self.value(&sanitize_display(&organization))
943                    ));
944                }
945
946                // Registrant contact details
947                if let Some(contact) = data.get_registrant_contact() {
948                    if contact.has_info() {
949                        output.push(format!("\n  {}:", self.label("Registrant Contact")));
950                        if let Some(ref email) = contact.email {
951                            output.push(format!(
952                                "    {}: {}",
953                                self.label("Email"),
954                                self.value(&sanitize_display(email))
955                            ));
956                        }
957                        if let Some(ref phone) = contact.phone {
958                            output.push(format!(
959                                "    {}: {}",
960                                self.label("Phone"),
961                                self.value(&sanitize_display(phone))
962                            ));
963                        }
964                        if let Some(ref address) = contact.address {
965                            output.push(format!(
966                                "    {}: {}",
967                                self.label("Address"),
968                                self.value(&sanitize_display(address))
969                            ));
970                        }
971                        if let Some(ref country) = contact.country {
972                            output.push(format!(
973                                "    {}: {}",
974                                self.label("Country"),
975                                self.value(&sanitize_display(country))
976                            ));
977                        }
978                    }
979                }
980
981                // Admin contact
982                if let Some(contact) = data.get_admin_contact() {
983                    if contact.has_info() {
984                        output.push(format!("\n  {}:", self.label("Admin Contact")));
985                        if let Some(ref name) = contact.name {
986                            output.push(format!(
987                                "    {}: {}",
988                                self.label("Name"),
989                                self.value(&sanitize_display(name))
990                            ));
991                        }
992                        if let Some(ref org) = contact.organization {
993                            output.push(format!(
994                                "    {}: {}",
995                                self.label("Organization"),
996                                self.value(&sanitize_display(org))
997                            ));
998                        }
999                        if let Some(ref email) = contact.email {
1000                            output.push(format!(
1001                                "    {}: {}",
1002                                self.label("Email"),
1003                                self.value(&sanitize_display(email))
1004                            ));
1005                        }
1006                        if let Some(ref phone) = contact.phone {
1007                            output.push(format!(
1008                                "    {}: {}",
1009                                self.label("Phone"),
1010                                self.value(&sanitize_display(phone))
1011                            ));
1012                        }
1013                    }
1014                }
1015
1016                // Tech contact
1017                if let Some(contact) = data.get_tech_contact() {
1018                    if contact.has_info() {
1019                        output.push(format!("\n  {}:", self.label("Tech Contact")));
1020                        if let Some(ref name) = contact.name {
1021                            output.push(format!(
1022                                "    {}: {}",
1023                                self.label("Name"),
1024                                self.value(&sanitize_display(name))
1025                            ));
1026                        }
1027                        if let Some(ref org) = contact.organization {
1028                            output.push(format!(
1029                                "    {}: {}",
1030                                self.label("Organization"),
1031                                self.value(&sanitize_display(org))
1032                            ));
1033                        }
1034                        if let Some(ref email) = contact.email {
1035                            output.push(format!(
1036                                "    {}: {}",
1037                                self.label("Email"),
1038                                self.value(&sanitize_display(email))
1039                            ));
1040                        }
1041                        if let Some(ref phone) = contact.phone {
1042                            output.push(format!(
1043                                "    {}: {}",
1044                                self.label("Phone"),
1045                                self.value(&sanitize_display(phone))
1046                            ));
1047                        }
1048                    }
1049                }
1050
1051                if let Some(created) = data.creation_date() {
1052                    output.push(format!(
1053                        "  {}: {}",
1054                        self.label("Created"),
1055                        self.value(&created.format("%Y-%m-%d").to_string())
1056                    ));
1057                }
1058
1059                if let Some(expires) = data.expiration_date() {
1060                    let days_until = (expires - chrono::Utc::now()).num_days();
1061                    let expiry_str = expires.format("%Y-%m-%d").to_string();
1062                    let status = self.format_expiry_status(&expiry_str, days_until);
1063                    output.push(format!("  {}: {}", self.label("Expires"), status));
1064                }
1065
1066                if !data.status.is_empty() {
1067                    output.push(format!("  {}:", self.label("Status")));
1068                    for status in &data.status {
1069                        output.push(format!("    - {}", self.value(&sanitize_display(status))));
1070                    }
1071                }
1072
1073                let nameservers = data.nameserver_names();
1074                if !nameservers.is_empty() {
1075                    output.push(format!("  {}:", self.label("Nameservers")));
1076                    for ns in &nameservers {
1077                        output.push(format!("    - {}", self.value(&sanitize_display(ns))));
1078                    }
1079                }
1080
1081                if data.is_dnssec_signed() {
1082                    output.push(format!(
1083                        "  {}: {}",
1084                        self.label("DNSSEC"),
1085                        self.success("signed")
1086                    ));
1087                }
1088
1089                if let Some(whois) = whois_fallback {
1090                    let mut extra = Vec::new();
1091
1092                    // Registrant (if RDAP didn't have it)
1093                    if data.get_registrant().is_none() {
1094                        if let Some(ref registrant) = whois.registrant {
1095                            extra.push(format!(
1096                                "    {}: {}",
1097                                self.label("Registrant"),
1098                                self.value(&sanitize_display(registrant))
1099                            ));
1100                        }
1101                    }
1102
1103                    // Organization (if RDAP didn't have it)
1104                    if data.get_registrant_organization().is_none() {
1105                        if let Some(ref org) = whois.organization {
1106                            extra.push(format!(
1107                                "    {}: {}",
1108                                self.label("Organization"),
1109                                self.value(&sanitize_display(org))
1110                            ));
1111                        }
1112                    }
1113
1114                    // Registrant contact details (if RDAP didn't have them)
1115                    let rdap_registrant = data.get_registrant_contact();
1116                    let rdap_has_registrant =
1117                        rdap_registrant.as_ref().is_some_and(|c| c.has_info());
1118                    if !rdap_has_registrant {
1119                        let has_whois_contact = whois.registrant_email.is_some()
1120                            || whois.registrant_phone.is_some()
1121                            || whois.registrant_address.is_some()
1122                            || whois.registrant_country.is_some();
1123                        if has_whois_contact {
1124                            extra.push(format!("\n    {}:", self.label("Registrant Contact")));
1125                            if let Some(ref email) = whois.registrant_email {
1126                                extra.push(format!(
1127                                    "      {}: {}",
1128                                    self.label("Email"),
1129                                    self.value(&sanitize_display(email))
1130                                ));
1131                            }
1132                            if let Some(ref phone) = whois.registrant_phone {
1133                                extra.push(format!(
1134                                    "      {}: {}",
1135                                    self.label("Phone"),
1136                                    self.value(&sanitize_display(phone))
1137                                ));
1138                            }
1139                            if let Some(ref address) = whois.registrant_address {
1140                                extra.push(format!(
1141                                    "      {}: {}",
1142                                    self.label("Address"),
1143                                    self.value(&sanitize_display(address))
1144                                ));
1145                            }
1146                            if let Some(ref country) = whois.registrant_country {
1147                                extra.push(format!(
1148                                    "      {}: {}",
1149                                    self.label("Country"),
1150                                    self.value(&sanitize_display(country))
1151                                ));
1152                            }
1153                        }
1154                    }
1155
1156                    // Admin contact (if RDAP didn't have it)
1157                    let rdap_has_admin = data.get_admin_contact().is_some_and(|c| c.has_info());
1158                    if !rdap_has_admin {
1159                        let has_whois_admin = whois.admin_name.is_some()
1160                            || whois.admin_email.is_some()
1161                            || whois.admin_phone.is_some();
1162                        if has_whois_admin {
1163                            extra.push(format!("\n    {}:", self.label("Admin Contact")));
1164                            if let Some(ref name) = whois.admin_name {
1165                                extra.push(format!(
1166                                    "      {}: {}",
1167                                    self.label("Name"),
1168                                    self.value(&sanitize_display(name))
1169                                ));
1170                            }
1171                            if let Some(ref org) = whois.admin_organization {
1172                                extra.push(format!(
1173                                    "      {}: {}",
1174                                    self.label("Organization"),
1175                                    self.value(&sanitize_display(org))
1176                                ));
1177                            }
1178                            if let Some(ref email) = whois.admin_email {
1179                                extra.push(format!(
1180                                    "      {}: {}",
1181                                    self.label("Email"),
1182                                    self.value(&sanitize_display(email))
1183                                ));
1184                            }
1185                            if let Some(ref phone) = whois.admin_phone {
1186                                extra.push(format!(
1187                                    "      {}: {}",
1188                                    self.label("Phone"),
1189                                    self.value(&sanitize_display(phone))
1190                                ));
1191                            }
1192                        }
1193                    }
1194
1195                    // Tech contact (if RDAP didn't have it)
1196                    let rdap_has_tech = data.get_tech_contact().is_some_and(|c| c.has_info());
1197                    if !rdap_has_tech {
1198                        let has_whois_tech = whois.tech_name.is_some()
1199                            || whois.tech_email.is_some()
1200                            || whois.tech_phone.is_some();
1201                        if has_whois_tech {
1202                            extra.push(format!("\n    {}:", self.label("Tech Contact")));
1203                            if let Some(ref name) = whois.tech_name {
1204                                extra.push(format!(
1205                                    "      {}: {}",
1206                                    self.label("Name"),
1207                                    self.value(&sanitize_display(name))
1208                                ));
1209                            }
1210                            if let Some(ref org) = whois.tech_organization {
1211                                extra.push(format!(
1212                                    "      {}: {}",
1213                                    self.label("Organization"),
1214                                    self.value(&sanitize_display(org))
1215                                ));
1216                            }
1217                            if let Some(ref email) = whois.tech_email {
1218                                extra.push(format!(
1219                                    "      {}: {}",
1220                                    self.label("Email"),
1221                                    self.value(&sanitize_display(email))
1222                                ));
1223                            }
1224                            if let Some(ref phone) = whois.tech_phone {
1225                                extra.push(format!(
1226                                    "      {}: {}",
1227                                    self.label("Phone"),
1228                                    self.value(&sanitize_display(phone))
1229                                ));
1230                            }
1231                        }
1232                    }
1233
1234                    // Updated date (RDAP doesn't typically expose this)
1235                    if let Some(updated) = whois.updated_date {
1236                        extra.push(format!(
1237                            "    {}: {}",
1238                            self.label("Updated"),
1239                            self.value(&updated.format("%Y-%m-%d").to_string())
1240                        ));
1241                    }
1242
1243                    // DNSSEC (if RDAP didn't show it)
1244                    if !data.is_dnssec_signed() {
1245                        if let Some(ref dnssec) = whois.dnssec {
1246                            extra.push(format!(
1247                                "    {}: {}",
1248                                self.label("DNSSEC"),
1249                                self.value(&sanitize_display(dnssec))
1250                            ));
1251                        }
1252                    }
1253
1254                    // WHOIS server
1255                    if !whois.whois_server.is_empty() {
1256                        extra.push(format!(
1257                            "    {}: {}",
1258                            self.label("WHOIS Server"),
1259                            self.value(&sanitize_display(&whois.whois_server))
1260                        ));
1261                    }
1262
1263                    if !extra.is_empty() {
1264                        output.push(format!("\n  {}", self.label("Additional WHOIS data:")));
1265                        output.extend(extra);
1266                    }
1267                }
1268            }
1269            LookupResult::Whois {
1270                data, rdap_error, ..
1271            } => {
1272                let source_note = if rdap_error.is_some() {
1273                    "WHOIS (RDAP unavailable)"
1274                } else {
1275                    "WHOIS"
1276                };
1277                output.push(format!(
1278                    "  {}: {}",
1279                    self.label("Source"),
1280                    self.warning(source_note)
1281                ));
1282
1283                if let Some(ref error) = rdap_error {
1284                    output.push(format!(
1285                        "  {}: {}",
1286                        self.label("RDAP Error"),
1287                        self.error(error)
1288                    ));
1289                }
1290
1291                if let Some(ref registrar) = data.registrar {
1292                    output.push(format!(
1293                        "  {}: {}",
1294                        self.label("Registrar"),
1295                        self.value(&sanitize_display(registrar))
1296                    ));
1297                }
1298
1299                if let Some(ref registrant) = data.registrant {
1300                    output.push(format!(
1301                        "  {}: {}",
1302                        self.label("Registrant"),
1303                        self.value(&sanitize_display(registrant))
1304                    ));
1305                }
1306
1307                if let Some(ref organization) = data.organization {
1308                    output.push(format!(
1309                        "  {}: {}",
1310                        self.label("Organization"),
1311                        self.value(&sanitize_display(organization))
1312                    ));
1313                }
1314
1315                // Registrant contact details
1316                let has_registrant_details = data.registrant_email.is_some()
1317                    || data.registrant_phone.is_some()
1318                    || data.registrant_address.is_some()
1319                    || data.registrant_country.is_some();
1320
1321                if has_registrant_details {
1322                    output.push(format!("\n  {}:", self.label("Registrant Contact")));
1323                    if let Some(ref email) = data.registrant_email {
1324                        output.push(format!(
1325                            "    {}: {}",
1326                            self.label("Email"),
1327                            self.value(&sanitize_display(email))
1328                        ));
1329                    }
1330                    if let Some(ref phone) = data.registrant_phone {
1331                        output.push(format!(
1332                            "    {}: {}",
1333                            self.label("Phone"),
1334                            self.value(&sanitize_display(phone))
1335                        ));
1336                    }
1337                    if let Some(ref address) = data.registrant_address {
1338                        output.push(format!(
1339                            "    {}: {}",
1340                            self.label("Address"),
1341                            self.value(&sanitize_display(address))
1342                        ));
1343                    }
1344                    if let Some(ref country) = data.registrant_country {
1345                        output.push(format!(
1346                            "    {}: {}",
1347                            self.label("Country"),
1348                            self.value(&sanitize_display(country))
1349                        ));
1350                    }
1351                }
1352
1353                // Admin contact
1354                let has_admin_contact = data.admin_name.is_some()
1355                    || data.admin_organization.is_some()
1356                    || data.admin_email.is_some()
1357                    || data.admin_phone.is_some();
1358
1359                if has_admin_contact {
1360                    output.push(format!("\n  {}:", self.label("Admin Contact")));
1361                    if let Some(ref name) = data.admin_name {
1362                        output.push(format!(
1363                            "    {}: {}",
1364                            self.label("Name"),
1365                            self.value(&sanitize_display(name))
1366                        ));
1367                    }
1368                    if let Some(ref org) = data.admin_organization {
1369                        output.push(format!(
1370                            "    {}: {}",
1371                            self.label("Organization"),
1372                            self.value(&sanitize_display(org))
1373                        ));
1374                    }
1375                    if let Some(ref email) = data.admin_email {
1376                        output.push(format!(
1377                            "    {}: {}",
1378                            self.label("Email"),
1379                            self.value(&sanitize_display(email))
1380                        ));
1381                    }
1382                    if let Some(ref phone) = data.admin_phone {
1383                        output.push(format!(
1384                            "    {}: {}",
1385                            self.label("Phone"),
1386                            self.value(&sanitize_display(phone))
1387                        ));
1388                    }
1389                }
1390
1391                // Tech contact
1392                let has_tech_contact = data.tech_name.is_some()
1393                    || data.tech_organization.is_some()
1394                    || data.tech_email.is_some()
1395                    || data.tech_phone.is_some();
1396
1397                if has_tech_contact {
1398                    output.push(format!("\n  {}:", self.label("Tech Contact")));
1399                    if let Some(ref name) = data.tech_name {
1400                        output.push(format!(
1401                            "    {}: {}",
1402                            self.label("Name"),
1403                            self.value(&sanitize_display(name))
1404                        ));
1405                    }
1406                    if let Some(ref org) = data.tech_organization {
1407                        output.push(format!(
1408                            "    {}: {}",
1409                            self.label("Organization"),
1410                            self.value(&sanitize_display(org))
1411                        ));
1412                    }
1413                    if let Some(ref email) = data.tech_email {
1414                        output.push(format!(
1415                            "    {}: {}",
1416                            self.label("Email"),
1417                            self.value(&sanitize_display(email))
1418                        ));
1419                    }
1420                    if let Some(ref phone) = data.tech_phone {
1421                        output.push(format!(
1422                            "    {}: {}",
1423                            self.label("Phone"),
1424                            self.value(&sanitize_display(phone))
1425                        ));
1426                    }
1427                }
1428
1429                if let Some(created) = data.creation_date {
1430                    output.push(format!(
1431                        "  {}: {}",
1432                        self.label("Created"),
1433                        self.value(&created.format("%Y-%m-%d").to_string())
1434                    ));
1435                }
1436
1437                if let Some(expires) = data.expiration_date {
1438                    let days_until = (expires - chrono::Utc::now()).num_days();
1439                    let expiry_str = expires.format("%Y-%m-%d").to_string();
1440                    let status = self.format_expiry_status(&expiry_str, days_until);
1441                    output.push(format!("  {}: {}", self.label("Expires"), status));
1442                }
1443
1444                if !data.status.is_empty() {
1445                    output.push(format!("  {}:", self.label("Status")));
1446                    for status in &data.status {
1447                        output.push(format!("    - {}", self.value(&sanitize_display(status))));
1448                    }
1449                }
1450
1451                if !data.nameservers.is_empty() {
1452                    output.push(format!("  {}:", self.label("Nameservers")));
1453                    for ns in &data.nameservers {
1454                        output.push(format!("    - {}", self.value(&sanitize_display(ns))));
1455                    }
1456                }
1457
1458                if let Some(ref dnssec) = data.dnssec {
1459                    output.push(format!(
1460                        "  {}: {}",
1461                        self.label("DNSSEC"),
1462                        self.value(&sanitize_display(dnssec))
1463                    ));
1464                }
1465            }
1466            LookupResult::Available {
1467                data,
1468                rdap_error,
1469                whois_error,
1470                whois_data,
1471            } => {
1472                let source_note = if whois_data.is_some() {
1473                    "WHOIS (RDAP unavailable)"
1474                } else {
1475                    "availability check (RDAP and WHOIS failed)"
1476                };
1477                output.push(format!(
1478                    "  {}: {}",
1479                    self.label("Source"),
1480                    self.warning(source_note)
1481                ));
1482
1483                let verdict_colored = match data.verdict() {
1484                    "available" => self.success("AVAILABLE"),
1485                    "likely_available" => self.warning("MAY BE AVAILABLE"),
1486                    "registered" => self.value("REGISTERED"),
1487                    "likely_registered" => self.warning("LIKELY REGISTERED"),
1488                    _ => self.error("UNKNOWN"),
1489                };
1490                output.push(format!("  {}: {}", self.label("Verdict"), verdict_colored));
1491
1492                // Confidence colouring is purely about certainty, independent of
1493                // the registered/available answer.
1494                let confidence_colored = match data.confidence.as_str() {
1495                    "high" => self.success(&data.confidence),
1496                    "medium" => self.warning(&data.confidence),
1497                    _ => self.error(&data.confidence),
1498                };
1499                output.push(format!(
1500                    "  {}: {}",
1501                    self.label("Confidence"),
1502                    confidence_colored
1503                ));
1504
1505                output.push(format!(
1506                    "  {}: {}",
1507                    self.label("Method"),
1508                    self.value(&sanitize_display(&data.method))
1509                ));
1510
1511                if let Some(details) = &data.details {
1512                    output.push(format!(
1513                        "  {}: {}",
1514                        self.label("Details"),
1515                        self.value(&sanitize_display(details))
1516                    ));
1517                }
1518
1519                if !rdap_error.is_empty() {
1520                    output.push(format!(
1521                        "  {}: {}",
1522                        self.label("RDAP Error"),
1523                        self.error(rdap_error)
1524                    ));
1525                }
1526                if !whois_error.is_empty() {
1527                    output.push(format!(
1528                        "  {}: {}",
1529                        self.label("WHOIS Error"),
1530                        self.error(whois_error)
1531                    ));
1532                }
1533
1534                if let Some(w) = whois_data {
1535                    let mut extra = Vec::new();
1536                    if !w.nameservers.is_empty() {
1537                        extra.push(format!(
1538                            "    {}: {}",
1539                            self.label("Nameservers"),
1540                            self.value(&sanitize_display(&w.nameservers.join(", ")))
1541                        ));
1542                    }
1543                    if !w.status.is_empty() {
1544                        extra.push(format!(
1545                            "    {}: {}",
1546                            self.label("Status"),
1547                            self.value(&sanitize_display(&w.status.join(", ")))
1548                        ));
1549                    }
1550                    if let Some(ref dnssec) = w.dnssec {
1551                        extra.push(format!(
1552                            "    {}: {}",
1553                            self.label("DNSSEC"),
1554                            self.value(&sanitize_display(dnssec))
1555                        ));
1556                    }
1557                    if !w.whois_server.is_empty() {
1558                        extra.push(format!(
1559                            "    {}: {}",
1560                            self.label("WHOIS Server"),
1561                            self.value(&sanitize_display(&w.whois_server))
1562                        ));
1563                    }
1564                    if !extra.is_empty() {
1565                        output.push(format!("  {}", self.label("Additional WHOIS data:")));
1566                        output.extend(extra);
1567                    }
1568                }
1569            }
1570        }
1571
1572        output.join("\n")
1573    }
1574
1575    fn format_status(&self, response: &StatusResponse) -> String {
1576        let mut output = Vec::new();
1577
1578        output.push(self.header(&format!("Status: {}", sanitize_display(&response.domain))));
1579
1580        // HTTP Status
1581        if let Some(status) = response.http_status {
1582            let status_text =
1583                sanitize_display(response.http_status_text.as_deref().unwrap_or("Unknown"));
1584            let status_display = if (200..300).contains(&status) {
1585                self.success(&format!("{} ({})", status, status_text))
1586            } else if (300..400).contains(&status) {
1587                self.warning(&format!("{} ({})", status, status_text))
1588            } else {
1589                self.error(&format!("{} ({})", status, status_text))
1590            };
1591            output.push(format!(
1592                "  {}: {}",
1593                self.label("HTTP Status"),
1594                status_display
1595            ));
1596        }
1597
1598        // Site Title
1599        if let Some(ref title) = response.title {
1600            output.push(format!(
1601                "  {}: {}",
1602                self.label("Site Title"),
1603                self.value(&sanitize_display(title))
1604            ));
1605        }
1606
1607        // SSL Certificate
1608        if let Some(ref cert) = response.certificate {
1609            output.push(format!("\n  {}:", self.label("SSL Certificate")));
1610            output.push(format!(
1611                "    {}: {}",
1612                self.label("Subject"),
1613                self.value(&sanitize_display(&cert.subject))
1614            ));
1615            output.push(format!(
1616                "    {}: {}",
1617                self.label("Issuer"),
1618                self.value(&sanitize_display(&cert.issuer))
1619            ));
1620
1621            let valid_status = if cert.is_valid {
1622                self.success("Valid")
1623            } else {
1624                self.error("Invalid")
1625            };
1626            output.push(format!("    {}: {}", self.label("Status"), valid_status));
1627
1628            if !cert.hostname_verified {
1629                output.push(format!(
1630                    "    {}",
1631                    self.error("WARNING: certificate hostname not verified")
1632                ));
1633            }
1634
1635            output.push(format!(
1636                "    {}: {}",
1637                self.label("Valid From"),
1638                self.value(&cert.valid_from.format("%Y-%m-%d").to_string())
1639            ));
1640
1641            let expiry_str = cert.valid_until.format("%Y-%m-%d").to_string();
1642            let expiry_display = if cert.days_until_expiry < 30 {
1643                self.error(&format!(
1644                    "{} ({} days!)",
1645                    expiry_str, cert.days_until_expiry
1646                ))
1647            } else if cert.days_until_expiry < 90 {
1648                self.warning(&format!("{} ({} days)", expiry_str, cert.days_until_expiry))
1649            } else {
1650                self.value(&format!("{} ({} days)", expiry_str, cert.days_until_expiry))
1651            };
1652            output.push(format!("    {}: {}", self.label("Expires"), expiry_display));
1653        } else {
1654            output.push(format!(
1655                "\n  {}: {}",
1656                self.label("SSL Certificate"),
1657                self.warning("Not available (HTTPS may not be configured)")
1658            ));
1659        }
1660
1661        // CAA policy (issuance-time authorization for certificate authorities)
1662        if let Some(ref caa) = response.caa {
1663            output.extend(self.render_caa_block(caa, "  "));
1664        }
1665
1666        // Domain Expiration
1667        if let Some(ref expiry) = response.domain_expiration {
1668            output.push(format!("\n  {}:", self.label("Domain Registration")));
1669
1670            if let Some(ref registrar) = expiry.registrar {
1671                output.push(format!(
1672                    "    {}: {}",
1673                    self.label("Registrar"),
1674                    self.value(&sanitize_display(registrar))
1675                ));
1676            }
1677
1678            let expiry_str = expiry.expiration_date.format("%Y-%m-%d").to_string();
1679            let expiry_display = if expiry.days_until_expiry < 30 {
1680                self.error(&format!(
1681                    "{} ({} days!)",
1682                    expiry_str, expiry.days_until_expiry
1683                ))
1684            } else if expiry.days_until_expiry < 90 {
1685                self.warning(&format!(
1686                    "{} ({} days)",
1687                    expiry_str, expiry.days_until_expiry
1688                ))
1689            } else {
1690                self.value(&format!(
1691                    "{} ({} days)",
1692                    expiry_str, expiry.days_until_expiry
1693                ))
1694            };
1695            output.push(format!("    {}: {}", self.label("Expires"), expiry_display));
1696        }
1697
1698        // DNS Resolution
1699        if let Some(ref dns) = response.dns_resolution {
1700            output.push(format!("\n  {}:", self.label("DNS Resolution")));
1701
1702            // Status line
1703            if dns.resolves {
1704                output.push(format!("    {}", self.success("✓ Resolving")));
1705            } else {
1706                output.push(format!("    {}", self.error("✗ Domain does not resolve")));
1707            }
1708
1709            // CNAME if present
1710            if let Some(ref cname) = dns.cname_target {
1711                output.push(format!(
1712                    "    {}: Aliases to {}",
1713                    self.label("CNAME"),
1714                    self.success(&sanitize_display(cname))
1715                ));
1716            }
1717
1718            // IPv4 addresses (A records)
1719            if !dns.a_records.is_empty() {
1720                output.push(format!("    {}:", self.label("IPv4 (A)")));
1721                for ip in &dns.a_records {
1722                    output.push(format!("      • {}", self.value(&sanitize_display(ip))));
1723                }
1724            }
1725
1726            // IPv6 addresses (AAAA records)
1727            if !dns.aaaa_records.is_empty() {
1728                output.push(format!("    {}:", self.label("IPv6 (AAAA)")));
1729                for ip in &dns.aaaa_records {
1730                    output.push(format!("      • {}", self.value(&sanitize_display(ip))));
1731                }
1732            }
1733
1734            // Nameservers
1735            if !dns.nameservers.is_empty() {
1736                output.push(format!("    {}:", self.label("Nameservers")));
1737                for ns in &dns.nameservers {
1738                    output.push(format!("      • {}", self.value(&sanitize_display(ns))));
1739                }
1740            }
1741        } else {
1742            output.push(format!(
1743                "\n  {}: {}",
1744                self.label("DNS Resolution"),
1745                self.warning("Check failed")
1746            ));
1747        }
1748
1749        // CAA note sits at the very bottom of the whole status output,
1750        // un-indented, with a blank separator line.
1751        if let Some(ref caa) = response.caa {
1752            self.push_caa_note_footer(&mut output, caa);
1753        }
1754
1755        output.join("\n")
1756    }
1757
1758    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
1759        let mut output = Vec::new();
1760
1761        let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
1762        let iter_str = format!(
1763            "Iteration {}/{}",
1764            iteration.iteration, iteration.total_iterations
1765        );
1766
1767        if let Some(ref error) = iteration.error {
1768            output.push(format!(
1769                "[{}] {}: {}",
1770                self.label(&time_str),
1771                iter_str,
1772                self.error(error)
1773            ));
1774            return output.join("\n");
1775        }
1776
1777        let record_count = iteration.record_count();
1778        let status = if iteration.iteration == 1 {
1779            "".to_string()
1780        } else if iteration.changed {
1781            format!(" ({})", self.warning("CHANGED"))
1782        } else {
1783            format!(" ({})", self.success("unchanged"))
1784        };
1785
1786        // Collect record values, trimming trailing dots
1787        let values: Vec<String> = iteration
1788            .records
1789            .iter()
1790            .map(|r| r.data.to_string().trim_end_matches('.').to_string())
1791            .collect();
1792
1793        output.push(format!(
1794            "[{}] {}: {} record(s){}",
1795            self.label(&time_str),
1796            iter_str,
1797            record_count,
1798            status
1799        ));
1800
1801        // Show records comma-separated on a single indented line
1802        if !values.is_empty() {
1803            output.push(format!("  {}", self.value(&values.join(", "))));
1804        }
1805
1806        // Show changes if any
1807        if !iteration.added.is_empty() {
1808            for added in &iteration.added {
1809                let value = added.trim_end_matches('.');
1810                output.push(format!("  {} {}", self.success("+"), self.success(value)));
1811            }
1812        }
1813        if !iteration.removed.is_empty() {
1814            for removed in &iteration.removed {
1815                let value = removed.trim_end_matches('.');
1816                output.push(format!("  {} {}", self.error("-"), self.error(value)));
1817            }
1818        }
1819
1820        output.join("\n")
1821    }
1822
1823    fn format_follow(&self, result: &FollowResult) -> String {
1824        let mut output = Vec::new();
1825
1826        output.push(self.header(&format!(
1827            "DNS Follow Complete: {} {}",
1828            result.domain, result.record_type
1829        )));
1830
1831        // Summary
1832        output.push(format!(
1833            "  {}: {}/{}",
1834            self.label("Iterations completed"),
1835            result.completed_iterations(),
1836            result.iterations_requested
1837        ));
1838
1839        if result.interrupted {
1840            output.push(format!(
1841                "  {}: {}",
1842                self.label("Status"),
1843                self.warning("Interrupted")
1844            ));
1845        }
1846
1847        output.push(format!(
1848            "  {}: {}",
1849            self.label("Total changes detected"),
1850            if result.total_changes > 0 {
1851                self.warning(&result.total_changes.to_string())
1852            } else {
1853                self.success(&result.total_changes.to_string())
1854            }
1855        ));
1856
1857        let duration = result.ended_at - result.started_at;
1858        output.push(format!(
1859            "  {}: {}",
1860            self.label("Duration"),
1861            self.value(&format_duration(duration))
1862        ));
1863
1864        // Show iteration details
1865        if !result.iterations.is_empty() {
1866            output.push(format!("\n  {}:", self.label("Iteration Details")));
1867            for iteration in &result.iterations {
1868                let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
1869                let status = if iteration.error.is_some() {
1870                    self.error("ERROR")
1871                } else if iteration.changed {
1872                    self.warning("CHANGED")
1873                } else if iteration.iteration == 1 {
1874                    self.value("initial")
1875                } else {
1876                    self.success("stable")
1877                };
1878
1879                output.push(format!(
1880                    "    [{}] #{}: {} record(s) - {}",
1881                    time_str,
1882                    iteration.iteration,
1883                    iteration.record_count(),
1884                    status
1885                ));
1886            }
1887        }
1888
1889        output.join("\n")
1890    }
1891
1892    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
1893        let mut output = Vec::new();
1894
1895        let status = if result.available {
1896            self.success("AVAILABLE")
1897        } else {
1898            self.error("TAKEN")
1899        };
1900        output.push(format!("{}: {}", sanitize_display(&result.domain), status));
1901        let confidence_colored = match result.confidence.as_str() {
1902            "high" => self.success(&result.confidence),
1903            "medium" => self.warning(&result.confidence),
1904            _ => self.error(&result.confidence),
1905        };
1906        output.push(format!(
1907            "  {}: {}",
1908            self.label("Confidence"),
1909            confidence_colored
1910        ));
1911        output.push(format!(
1912            "  {}: {}",
1913            self.label("Method"),
1914            self.value(&sanitize_display(&result.method))
1915        ));
1916        if let Some(ref details) = result.details {
1917            // `details` in `decide_fallback` can interpolate raw `rdap_err`
1918            // / `whois_err` strings — those originate from third-party
1919            // servers and may contain ANSI escapes. Strip before display
1920            // matching every other value-rendering site in this formatter.
1921            output.push(format!(
1922                "  {}: {}",
1923                self.label("Details"),
1924                self.value(&sanitize_display(details))
1925            ));
1926        }
1927
1928        output.join("\n")
1929    }
1930
1931    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
1932        let mut output = Vec::new();
1933
1934        output.push(format!(
1935            "DNSSEC Report for {}",
1936            self.success(&sanitize_display(&report.domain))
1937        ));
1938        output.push(String::new());
1939
1940        let status_colored = match report.status.as_str() {
1941            "secure" => self.success(&report.status),
1942            "insecure" | "partial" => self.warning(&report.status),
1943            _ => self.error(&report.status),
1944        };
1945        output.push(format!("  {}: {}", self.label("Status"), status_colored));
1946        let chain_colored = if report.chain_valid {
1947            self.success("valid")
1948        } else if report.has_ds_records && report.has_dnskey_records {
1949            self.error("invalid")
1950        } else {
1951            self.warning("n/a")
1952        };
1953        output.push(format!(
1954            "  {}: {}",
1955            self.label("Chain Valid"),
1956            chain_colored
1957        ));
1958        output.push(format!(
1959            "  {}: {}",
1960            self.label("Enabled"),
1961            self.value(&report.enabled.to_string())
1962        ));
1963        output.push(format!(
1964            "  {}: {}",
1965            self.label("DS Records"),
1966            self.value(&report.ds_records.len().to_string())
1967        ));
1968        output.push(format!(
1969            "  {}: {}",
1970            self.label("DNSKEY Records"),
1971            self.value(&report.dnskey_records.len().to_string())
1972        ));
1973
1974        if !report.ds_records.is_empty() {
1975            output.push(String::new());
1976            output.push(format!("  {}:", self.label("DS Records")));
1977            for ds in &report.ds_records {
1978                let match_indicator = if ds.matched_key && ds.digest_verified {
1979                    self.success("\u{2713} verified")
1980                } else if ds.matched_key {
1981                    self.error("\u{2717} digest mismatch")
1982                } else {
1983                    self.error("\u{2717} no matching key")
1984                };
1985                output.push(format!(
1986                    "    Key Tag: {}, Algorithm: {} ({}), Digest: {} ({}) [{}]",
1987                    ds.key_tag,
1988                    ds.algorithm,
1989                    sanitize_display(&ds.algorithm_name),
1990                    ds.digest_type,
1991                    sanitize_display(&ds.digest_type_name),
1992                    match_indicator,
1993                ));
1994            }
1995        }
1996
1997        if !report.dnskey_records.is_empty() {
1998            output.push(String::new());
1999            output.push(format!("  {}:", self.label("DNSKEY Records")));
2000            for key in &report.dnskey_records {
2001                let role = if key.is_ksk {
2002                    "KSK"
2003                } else if key.is_zsk {
2004                    "ZSK"
2005                } else {
2006                    "Other"
2007                };
2008                output.push(format!(
2009                    "    Key Tag: {}, Flags: {}, Role: {}, Algorithm: {} ({})",
2010                    key.key_tag,
2011                    key.flags,
2012                    role,
2013                    key.algorithm,
2014                    sanitize_display(&key.algorithm_name)
2015                ));
2016            }
2017        }
2018
2019        if !report.issues.is_empty() {
2020            output.push(String::new());
2021            output.push(format!("  {}:", self.label("Issues")));
2022            for issue in &report.issues {
2023                output.push(format!("    - {}", sanitize_display(issue)));
2024            }
2025        }
2026
2027        output.join("\n")
2028    }
2029
2030    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
2031        let mut output = Vec::new();
2032
2033        output.push(self.header(&format!("TLD Info: .{}", info.tld)));
2034
2035        output.push(format!(
2036            "  {}: {}",
2037            self.label("Type"),
2038            self.value(&info.tld_type)
2039        ));
2040
2041        if let Some(ref server) = info.whois_server {
2042            output.push(format!(
2043                "  {}: {}",
2044                self.label("WHOIS Server"),
2045                self.value(server)
2046            ));
2047        } else {
2048            output.push(format!(
2049                "  {}: {}",
2050                self.label("WHOIS Server"),
2051                self.warning("not available")
2052            ));
2053        }
2054
2055        if let Some(ref url) = info.rdap_url {
2056            output.push(format!("  {}: {}", self.label("RDAP URL"), self.value(url)));
2057        } else {
2058            output.push(format!(
2059                "  {}: {}",
2060                self.label("RDAP URL"),
2061                self.warning("not available")
2062            ));
2063        }
2064
2065        if let Some(ref url) = info.registry_url {
2066            output.push(format!("  {}: {}", self.label("Registry"), self.value(url)));
2067        } else {
2068            output.push(format!(
2069                "  {}: {}",
2070                self.label("Registry"),
2071                self.warning("not available")
2072            ));
2073        }
2074
2075        output.join("\n")
2076    }
2077
2078    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
2079        let mut output = Vec::new();
2080
2081        output.push(self.header(&format!(
2082            "DNS Comparison: {} {}",
2083            comparison.domain, comparison.record_type
2084        )));
2085
2086        // Match status
2087        if comparison.matches {
2088            output.push(format!("  {} Records match", self.success("✓")));
2089        } else {
2090            output.push(format!("  {} Records differ", self.error("✗")));
2091        }
2092        output.push(String::new());
2093
2094        // Server A
2095        if let Some(ref err) = comparison.server_a.error {
2096            output.push(format!(
2097                "  {} ({}): {}",
2098                self.label("Server A"),
2099                self.value(&sanitize_display(&comparison.server_a.nameserver)),
2100                self.error(&sanitize_display(err))
2101            ));
2102        } else {
2103            output.push(format!(
2104                "  {} ({}): {} records",
2105                self.label("Server A"),
2106                self.value(&sanitize_display(&comparison.server_a.nameserver)),
2107                self.value(&comparison.server_a.records.len().to_string())
2108            ));
2109            for record in &comparison.server_a.records {
2110                output.push(format!(
2111                    "    - {}",
2112                    self.value(&sanitize_display(&record.format_short()))
2113                ));
2114            }
2115        }
2116        output.push(String::new());
2117
2118        // Server B
2119        if let Some(ref err) = comparison.server_b.error {
2120            output.push(format!(
2121                "  {} ({}): {}",
2122                self.label("Server B"),
2123                self.value(&sanitize_display(&comparison.server_b.nameserver)),
2124                self.error(&sanitize_display(err))
2125            ));
2126        } else {
2127            output.push(format!(
2128                "  {} ({}): {} records",
2129                self.label("Server B"),
2130                self.value(&sanitize_display(&comparison.server_b.nameserver)),
2131                self.value(&comparison.server_b.records.len().to_string())
2132            ));
2133            for record in &comparison.server_b.records {
2134                output.push(format!(
2135                    "    - {}",
2136                    self.value(&sanitize_display(&record.format_short()))
2137                ));
2138            }
2139        }
2140        output.push(String::new());
2141
2142        // Common records
2143        output.push(format!(
2144            "  {}: {}",
2145            self.label("Common"),
2146            if comparison.common.is_empty() {
2147                self.warning("(none)")
2148            } else {
2149                self.value(&sanitize_display(&comparison.common.join(", ")))
2150            }
2151        ));
2152
2153        // Only in A
2154        output.push(format!(
2155            "  {}: {}",
2156            self.label(&format!(
2157                "Only in {}",
2158                sanitize_display(&comparison.server_a.nameserver)
2159            )),
2160            if comparison.only_in_a.is_empty() {
2161                self.warning("(none)")
2162            } else {
2163                self.error(&sanitize_display(&comparison.only_in_a.join(", ")))
2164            }
2165        ));
2166
2167        // Only in B
2168        output.push(format!(
2169            "  {}: {}",
2170            self.label(&format!(
2171                "Only in {}",
2172                sanitize_display(&comparison.server_b.nameserver)
2173            )),
2174            if comparison.only_in_b.is_empty() {
2175                self.warning("(none)")
2176            } else {
2177                self.error(&sanitize_display(&comparison.only_in_b.join(", ")))
2178            }
2179        ));
2180
2181        output.join("\n")
2182    }
2183
2184    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
2185        let mut output = Vec::new();
2186
2187        output.push(self.header(&format!("Subdomains: {}", sanitize_display(&result.domain))));
2188
2189        output.push(format!(
2190            "  {}: {}",
2191            self.label("Source"),
2192            self.value(&sanitize_display(&result.source))
2193        ));
2194        output.push(format!(
2195            "  {}: {}",
2196            self.label("Count"),
2197            self.value(&result.count.to_string())
2198        ));
2199
2200        if result.subdomains.is_empty() {
2201            output.push(format!("  {}", self.warning("No subdomains found")));
2202        } else {
2203            output.push(String::new());
2204            for subdomain in &result.subdomains {
2205                output.push(format!(
2206                    "    - {}",
2207                    self.value(&sanitize_display(subdomain))
2208                ));
2209            }
2210        }
2211
2212        output.join("\n")
2213    }
2214
2215    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
2216        let mut output = Vec::new();
2217
2218        output.push(self.header(&format!(
2219            "Diff: {} vs {}",
2220            sanitize_display(&diff.domain_a),
2221            sanitize_display(&diff.domain_b)
2222        )));
2223
2224        let domain_a = sanitize_display(&diff.domain_a);
2225        let domain_b = sanitize_display(&diff.domain_b);
2226        let sections = build_diff_sections(diff);
2227        let col_width = compute_column_width(&sections, &domain_a, &domain_b);
2228
2229        // Label gutter width: max of all field labels (not section titles).
2230        let label_width = sections
2231            .iter()
2232            .flat_map(|s| s.rows.iter().map(|r| r.label.chars().count()))
2233            .max()
2234            .unwrap_or(0);
2235
2236        // Indents and gutters
2237        let label_indent = "    "; // 4 spaces inside section
2238        let section_indent = "  "; // 2 spaces before section title
2239        let marker_gutter_width = 2; // "= " or "≠ "
2240                                     // Between label cell and marker: 2 spaces (inter-column gap).
2241                                     // Marker cell: "= " or "≠ " = 2 chars.
2242                                     // Total left pad before value column A = label_indent + label_width + 2 + 2.
2243        let header_left_pad = label_indent.chars().count() + label_width + 2 + marker_gutter_width;
2244
2245        // Column header block
2246        let header_line = format!(
2247            "{}{}   {}",
2248            " ".repeat(header_left_pad),
2249            self.label(&pad_right(&domain_a, col_width)),
2250            self.label(&domain_b)
2251        );
2252        let rule_a: String = "─".repeat(domain_a.chars().count());
2253        let rule_b: String = "─".repeat(domain_b.chars().count());
2254        let rule_line = format!(
2255            "{}{}   {}",
2256            " ".repeat(header_left_pad),
2257            self.label(&pad_right(&rule_a, col_width)),
2258            self.label(&rule_b)
2259        );
2260        output.push(String::new());
2261        output.push(header_line);
2262        output.push(rule_line);
2263
2264        for section in &sections {
2265            output.push(String::new());
2266            output.push(format!("{}{}", section_indent, self.label(section.title)));
2267
2268            for row in &section.rows {
2269                // Wrap each value column independently.
2270                let mut a_lines: Vec<String> = row
2271                    .a_values
2272                    .iter()
2273                    .flat_map(|v| wrap_cell(&sanitize_display(v), col_width))
2274                    .collect();
2275                let mut b_lines: Vec<String> = row
2276                    .b_values
2277                    .iter()
2278                    .flat_map(|v| wrap_cell(&sanitize_display(v), col_width))
2279                    .collect();
2280
2281                // Pad the shorter list with blank lines so rows align.
2282                let rows_needed = a_lines.len().max(b_lines.len()).max(1);
2283                while a_lines.len() < rows_needed {
2284                    a_lines.push(String::new());
2285                }
2286                while b_lines.len() < rows_needed {
2287                    b_lines.push(String::new());
2288                }
2289
2290                let marker_glyph = if row.matches { "=" } else { "≠" };
2291                let color = |s: &str| -> String {
2292                    if row.matches {
2293                        self.success(s)
2294                    } else {
2295                        self.error(s)
2296                    }
2297                };
2298
2299                for (i, (a, b)) in a_lines.iter().zip(b_lines.iter()).enumerate() {
2300                    let label_cell = if i == 0 {
2301                        format!("{}{}", label_indent, pad_right(row.label, label_width))
2302                    } else {
2303                        format!("{}{}", label_indent, " ".repeat(label_width))
2304                    };
2305                    let marker_cell = if i == 0 {
2306                        format!("{} ", color(marker_glyph))
2307                    } else {
2308                        "  ".to_string()
2309                    };
2310                    let color_value = |s: &str, raw: &str| -> String {
2311                        if raw.trim() == EMPTY_PLACEHOLDER {
2312                            self.dim(s)
2313                        } else {
2314                            color(s)
2315                        }
2316                    };
2317                    let a_cell = color_value(&pad_right(a, col_width), a);
2318                    let b_cell = color_value(b, b);
2319                    output.push(format!(
2320                        "{}  {}{}   {}",
2321                        self.label(&label_cell),
2322                        marker_cell,
2323                        a_cell,
2324                        b_cell
2325                    ));
2326                }
2327            }
2328        }
2329
2330        output.join("\n")
2331    }
2332
2333    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
2334        let mut output = Vec::new();
2335
2336        output.push(self.header(&format!("SSL Report: {}", sanitize_display(&report.domain))));
2337
2338        output.push(format!(
2339            "  {}: {}",
2340            self.label("Valid"),
2341            if report.is_valid {
2342                self.success("yes")
2343            } else {
2344                self.error("no")
2345            }
2346        ));
2347        output.push(format!(
2348            "  {}: {}",
2349            self.label("Days Until Expiry"),
2350            self.value(&report.days_until_expiry.to_string())
2351        ));
2352
2353        if let Some(ref proto) = report.protocol_version {
2354            output.push(format!(
2355                "  {}: {}",
2356                self.label("Protocol"),
2357                self.value(&sanitize_display(proto))
2358            ));
2359        }
2360
2361        if !report.san_names.is_empty() {
2362            let sanitized_sans: Vec<String> = report
2363                .san_names
2364                .iter()
2365                .map(|s| sanitize_display(s))
2366                .collect();
2367            output.push(format!(
2368                "  {}: {}",
2369                self.label("SANs"),
2370                self.value(&sanitized_sans.join(", "))
2371            ));
2372        }
2373
2374        if !report.chain.is_empty() {
2375            output.push(String::new());
2376            output.push(format!("  {}:", self.label("Certificate Chain")));
2377            for (i, cert) in report.chain.iter().enumerate() {
2378                output.push(format!(
2379                    "    [{}] {}",
2380                    i,
2381                    self.value(&sanitize_display(&cert.subject))
2382                ));
2383                output.push(format!(
2384                    "        {}: {}",
2385                    self.label("Issuer"),
2386                    self.value(&sanitize_display(&cert.issuer))
2387                ));
2388                if let Some(ref alg) = cert.signature_algorithm {
2389                    output.push(format!(
2390                        "        {}: {}",
2391                        self.label("Algorithm"),
2392                        self.value(&sanitize_display(alg))
2393                    ));
2394                }
2395                if let Some(ref key_type) = cert.key_type {
2396                    let key_info = if let Some(bits) = cert.key_bits {
2397                        format!("{} ({} bits)", sanitize_display(key_type), bits)
2398                    } else {
2399                        sanitize_display(key_type)
2400                    };
2401                    output.push(format!(
2402                        "        {}: {}",
2403                        self.label("Key"),
2404                        self.value(&key_info)
2405                    ));
2406                }
2407                output.push(format!(
2408                    "        {}: {} to {}",
2409                    self.label("Validity"),
2410                    self.value(&cert.valid_from.format("%Y-%m-%d").to_string()),
2411                    self.value(&cert.valid_until.format("%Y-%m-%d").to_string())
2412                ));
2413            }
2414        }
2415
2416        if let Some(ref caa) = report.caa {
2417            output.extend(self.render_caa_block(caa, "  "));
2418            self.push_caa_note_footer(&mut output, caa);
2419        }
2420
2421        output.join("\n")
2422    }
2423
2424    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
2425        let mut output = Vec::new();
2426
2427        output.push(self.header("Domain Watch Report"));
2428
2429        output.push(format!(
2430            "  {}: {}",
2431            self.label("Checked"),
2432            self.value(
2433                &report
2434                    .checked_at
2435                    .format("%Y-%m-%d %H:%M:%S UTC")
2436                    .to_string()
2437            )
2438        ));
2439        output.push(format!(
2440            "  {}: {} domains, {} warnings",
2441            self.label("Total"),
2442            self.value(&report.total.to_string()),
2443            if report.warnings > 0 {
2444                self.warning(&report.warnings.to_string())
2445            } else {
2446                self.value(&report.warnings.to_string())
2447            }
2448        ));
2449
2450        for r in &report.results {
2451            output.push(String::new());
2452
2453            let icon = if r.issues.is_empty() {
2454                self.success("v")
2455            } else {
2456                self.warning("!")
2457            };
2458            output.push(format!(
2459                "  {} {}",
2460                icon,
2461                self.value(&sanitize_display(&r.domain))
2462            ));
2463
2464            // Condensed status line: SSL | Domain | HTTP
2465            let ssl_str = r
2466                .ssl_days_remaining
2467                .map(|d| format!("{} days", d))
2468                .unwrap_or_else(|| "N/A".to_string());
2469            let dom_str = r
2470                .domain_days_remaining
2471                .map(|d| format!("{} days", d))
2472                .unwrap_or_else(|| "N/A".to_string());
2473            let http_str = r
2474                .http_status
2475                .map(|s| s.to_string())
2476                .unwrap_or_else(|| "N/A".to_string());
2477
2478            output.push(format!(
2479                "      {}: {} | {}: {} | {}: {}",
2480                self.label("SSL"),
2481                self.value(&ssl_str),
2482                self.label("Domain"),
2483                self.value(&dom_str),
2484                self.label("HTTP"),
2485                self.value(&http_str)
2486            ));
2487
2488            if !r.issues.is_empty() {
2489                output.push(format!("      {}:", self.label("Issues")));
2490                for issue in &r.issues {
2491                    output.push(format!(
2492                        "        - {}",
2493                        self.warning(&sanitize_display(issue))
2494                    ));
2495                }
2496            }
2497        }
2498
2499        output.join("\n")
2500    }
2501
2502    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
2503        let mut output = Vec::new();
2504
2505        let source_str = match info.source {
2506            crate::domain_info::DomainInfoSource::Both => "both",
2507            crate::domain_info::DomainInfoSource::Rdap => "rdap",
2508            crate::domain_info::DomainInfoSource::Whois => "whois",
2509            crate::domain_info::DomainInfoSource::Available => "available",
2510        };
2511
2512        output.push(self.header(&format!(
2513            "Domain Info: {} (source: {})",
2514            sanitize_display(&info.domain),
2515            source_str
2516        )));
2517
2518        if let Some(verdict) = &info.availability_verdict {
2519            let colored = match verdict.as_str() {
2520                "available" => self.success("AVAILABLE"),
2521                "likely_available" => self.warning("MAY BE AVAILABLE"),
2522                "registered" => self.value("REGISTERED"),
2523                "likely_registered" => self.warning("LIKELY REGISTERED"),
2524                _ => self.error("UNKNOWN"),
2525            };
2526            output.push(format!("  {}: {}", self.label("Status"), colored));
2527        }
2528
2529        // Registration
2530        if let Some(ref registrar) = info.registrar {
2531            output.push(format!(
2532                "  {}: {}",
2533                self.label("Registrar"),
2534                self.value(&sanitize_display(registrar))
2535            ));
2536        }
2537        if let Some(ref registrant) = info.registrant {
2538            output.push(format!(
2539                "  {}: {}",
2540                self.label("Registrant"),
2541                self.value(&sanitize_display(registrant))
2542            ));
2543        }
2544        if let Some(ref organization) = info.organization {
2545            output.push(format!(
2546                "  {}: {}",
2547                self.label("Organization"),
2548                self.value(&sanitize_display(organization))
2549            ));
2550        }
2551
2552        // Dates
2553        if let Some(ref created) = info.creation_date {
2554            output.push(format!(
2555                "  {}: {}",
2556                self.label("Created"),
2557                self.value(&created.format("%Y-%m-%d").to_string())
2558            ));
2559        }
2560        if let Some(ref expires) = info.expiration_date {
2561            output.push(format!(
2562                "  {}: {}",
2563                self.label("Expires"),
2564                self.value(&expires.format("%Y-%m-%d").to_string())
2565            ));
2566        }
2567        if let Some(ref updated) = info.updated_date {
2568            output.push(format!(
2569                "  {}: {}",
2570                self.label("Updated"),
2571                self.value(&updated.format("%Y-%m-%d").to_string())
2572            ));
2573        }
2574
2575        // DNS
2576        if !info.nameservers.is_empty() {
2577            output.push(format!(
2578                "  {}: {}",
2579                self.label("Nameservers"),
2580                self.value(&info.nameservers.join(", "))
2581            ));
2582        }
2583        if !info.status.is_empty() {
2584            output.push(format!(
2585                "  {}: {}",
2586                self.label("Status"),
2587                self.value(&info.status.join(", "))
2588            ));
2589        }
2590        if let Some(ref dnssec) = info.dnssec {
2591            output.push(format!(
2592                "  {}: {}",
2593                self.label("DNSSEC"),
2594                self.value(&sanitize_display(dnssec))
2595            ));
2596        }
2597
2598        // Registrant Contact
2599        let has_registrant_contact = info.registrant_email.is_some()
2600            || info.registrant_phone.is_some()
2601            || info.registrant_address.is_some()
2602            || info.registrant_country.is_some();
2603        if has_registrant_contact {
2604            output.push(format!("\n  {}:", self.label("Registrant Contact")));
2605            if let Some(ref email) = info.registrant_email {
2606                output.push(format!(
2607                    "    {}: {}",
2608                    self.label("Email"),
2609                    self.value(&sanitize_display(email))
2610                ));
2611            }
2612            if let Some(ref phone) = info.registrant_phone {
2613                output.push(format!(
2614                    "    {}: {}",
2615                    self.label("Phone"),
2616                    self.value(&sanitize_display(phone))
2617                ));
2618            }
2619            if let Some(ref address) = info.registrant_address {
2620                output.push(format!(
2621                    "    {}: {}",
2622                    self.label("Address"),
2623                    self.value(&sanitize_display(address))
2624                ));
2625            }
2626            if let Some(ref country) = info.registrant_country {
2627                output.push(format!(
2628                    "    {}: {}",
2629                    self.label("Country"),
2630                    self.value(&sanitize_display(country))
2631                ));
2632            }
2633        }
2634
2635        // Admin Contact
2636        let has_admin_contact = info.admin_name.is_some()
2637            || info.admin_organization.is_some()
2638            || info.admin_email.is_some()
2639            || info.admin_phone.is_some();
2640        if has_admin_contact {
2641            output.push(format!("\n  {}:", self.label("Admin Contact")));
2642            if let Some(ref name) = info.admin_name {
2643                output.push(format!(
2644                    "    {}: {}",
2645                    self.label("Name"),
2646                    self.value(&sanitize_display(name))
2647                ));
2648            }
2649            if let Some(ref org) = info.admin_organization {
2650                output.push(format!(
2651                    "    {}: {}",
2652                    self.label("Organization"),
2653                    self.value(&sanitize_display(org))
2654                ));
2655            }
2656            if let Some(ref email) = info.admin_email {
2657                output.push(format!(
2658                    "    {}: {}",
2659                    self.label("Email"),
2660                    self.value(&sanitize_display(email))
2661                ));
2662            }
2663            if let Some(ref phone) = info.admin_phone {
2664                output.push(format!(
2665                    "    {}: {}",
2666                    self.label("Phone"),
2667                    self.value(&sanitize_display(phone))
2668                ));
2669            }
2670        }
2671
2672        // Tech Contact
2673        let has_tech_contact = info.tech_name.is_some()
2674            || info.tech_organization.is_some()
2675            || info.tech_email.is_some()
2676            || info.tech_phone.is_some();
2677        if has_tech_contact {
2678            output.push(format!("\n  {}:", self.label("Tech Contact")));
2679            if let Some(ref name) = info.tech_name {
2680                output.push(format!(
2681                    "    {}: {}",
2682                    self.label("Name"),
2683                    self.value(&sanitize_display(name))
2684                ));
2685            }
2686            if let Some(ref org) = info.tech_organization {
2687                output.push(format!(
2688                    "    {}: {}",
2689                    self.label("Organization"),
2690                    self.value(&sanitize_display(org))
2691                ));
2692            }
2693            if let Some(ref email) = info.tech_email {
2694                output.push(format!(
2695                    "    {}: {}",
2696                    self.label("Email"),
2697                    self.value(&sanitize_display(email))
2698                ));
2699            }
2700            if let Some(ref phone) = info.tech_phone {
2701                output.push(format!(
2702                    "    {}: {}",
2703                    self.label("Phone"),
2704                    self.value(&sanitize_display(phone))
2705                ));
2706            }
2707        }
2708
2709        // Protocol Metadata
2710        let has_metadata = info.whois_server.is_some() || info.rdap_url.is_some();
2711        if has_metadata {
2712            output.push(format!("\n  {}:", self.label("Protocol Metadata")));
2713            if let Some(ref whois_server) = info.whois_server {
2714                output.push(format!(
2715                    "    {}: {}",
2716                    self.label("WHOIS Server"),
2717                    self.value(&sanitize_display(whois_server))
2718                ));
2719            }
2720            if let Some(ref rdap_url) = info.rdap_url {
2721                output.push(format!(
2722                    "    {}: {}",
2723                    self.label("RDAP URL"),
2724                    self.value(&sanitize_display(rdap_url))
2725                ));
2726            }
2727        }
2728
2729        output.join("\n")
2730    }
2731}
2732
2733/// Compares two `Option<String>` values for equality after trimming whitespace.
2734/// Empty-after-trim is treated as `None`.
2735fn eq_opt_str_trimmed(a: &Option<String>, b: &Option<String>) -> bool {
2736    let norm = |o: &Option<String>| -> Option<String> {
2737        o.as_ref()
2738            .map(|s| s.trim().to_string())
2739            .filter(|s| !s.is_empty())
2740    };
2741    norm(a) == norm(b)
2742}
2743
2744/// Compares two string lists as sets: trims each item, drops empty items,
2745/// then checks that the sorted multisets are equal.
2746fn eq_as_set(a: &[String], b: &[String]) -> bool {
2747    let mut an: Vec<String> = a
2748        .iter()
2749        .map(|s| s.trim().to_string())
2750        .filter(|s| !s.is_empty())
2751        .collect();
2752    let mut bn: Vec<String> = b
2753        .iter()
2754        .map(|s| s.trim().to_string())
2755        .filter(|s| !s.is_empty())
2756        .collect();
2757    an.sort();
2758    bn.sort();
2759    an == bn
2760}
2761
2762/// Wraps `text` into lines no wider than `max_width` display chars.
2763/// Breaks at the last ASCII whitespace within the window when possible;
2764/// otherwise hard-breaks at the cap. Widths are measured in `chars().count()`
2765/// which is correct for ASCII and a reasonable fallback for other inputs.
2766fn wrap_cell(text: &str, max_width: usize) -> Vec<String> {
2767    let width = max_width.max(1);
2768    if text.is_empty() {
2769        return vec![String::new()];
2770    }
2771
2772    let chars: Vec<char> = text.chars().collect();
2773    if chars.len() <= width {
2774        return vec![text.to_string()];
2775    }
2776
2777    let mut out = Vec::new();
2778    let mut i = 0;
2779    while i < chars.len() {
2780        let remaining = chars.len() - i;
2781        if remaining <= width {
2782            out.push(chars[i..].iter().collect());
2783            break;
2784        }
2785        // Search for last whitespace in chars[i .. i+width]
2786        let window_end = i + width;
2787        let break_at = (i..window_end).rev().find(|&k| chars[k].is_whitespace());
2788        match break_at {
2789            Some(k) if k > i => {
2790                out.push(chars[i..k].iter().collect());
2791                i = k + 1; // skip the whitespace itself
2792            }
2793            _ => {
2794                // No whitespace in window (or whitespace at index i): hard break.
2795                out.push(chars[i..window_end].iter().collect());
2796                i = window_end;
2797            }
2798        }
2799    }
2800
2801    out
2802}
2803
2804/// One labeled row in the rendered diff table. `a_values` / `b_values` each
2805/// hold one item per rendered line — scalars are single-element; multi-value
2806/// fields (A records, nameservers) hold one entry per item.
2807struct DiffRow {
2808    label: &'static str,
2809    a_values: Vec<String>,
2810    b_values: Vec<String>,
2811    matches: bool,
2812}
2813
2814struct DiffSection {
2815    title: &'static str,
2816    rows: Vec<DiffRow>,
2817}
2818
2819/// The placeholder rendered for `None` or empty values.
2820const EMPTY_PLACEHOLDER: &str = "—";
2821
2822fn opt_or_placeholder(o: &Option<String>) -> String {
2823    o.as_ref()
2824        .map(|s| s.trim().to_string())
2825        .filter(|s| !s.is_empty())
2826        .unwrap_or_else(|| EMPTY_PLACEHOLDER.to_string())
2827}
2828
2829fn opt_i64_or_placeholder(o: &Option<i64>) -> String {
2830    o.map(|n| n.to_string())
2831        .unwrap_or_else(|| EMPTY_PLACEHOLDER.to_string())
2832}
2833
2834fn opt_bool_or_placeholder(o: &Option<bool>) -> String {
2835    match o {
2836        Some(true) => "yes".to_string(),
2837        Some(false) => "no".to_string(),
2838        None => EMPTY_PLACEHOLDER.to_string(),
2839    }
2840}
2841
2842fn bool_as_str(b: bool) -> String {
2843    if b {
2844        "yes".to_string()
2845    } else {
2846        "no".to_string()
2847    }
2848}
2849
2850fn list_or_placeholder(list: &[String]) -> Vec<String> {
2851    let cleaned: Vec<String> = list
2852        .iter()
2853        .map(|s| s.trim().to_string())
2854        .filter(|s| !s.is_empty())
2855        .collect();
2856    if cleaned.is_empty() {
2857        vec![EMPTY_PLACEHOLDER.to_string()]
2858    } else {
2859        cleaned
2860    }
2861}
2862
2863fn build_diff_sections(diff: &crate::diff::DomainDiff) -> Vec<DiffSection> {
2864    let reg = &diff.registration;
2865    let dns = &diff.dns;
2866    let ssl = &diff.ssl;
2867
2868    let registration = DiffSection {
2869        title: "Registration",
2870        rows: vec![
2871            DiffRow {
2872                label: "Registrar",
2873                a_values: vec![opt_or_placeholder(&reg.registrar.0)],
2874                b_values: vec![opt_or_placeholder(&reg.registrar.1)],
2875                matches: eq_opt_str_trimmed(&reg.registrar.0, &reg.registrar.1),
2876            },
2877            DiffRow {
2878                label: "Organization",
2879                a_values: vec![opt_or_placeholder(&reg.organization.0)],
2880                b_values: vec![opt_or_placeholder(&reg.organization.1)],
2881                matches: eq_opt_str_trimmed(&reg.organization.0, &reg.organization.1),
2882            },
2883            DiffRow {
2884                label: "Created",
2885                a_values: vec![opt_or_placeholder(&reg.created.0)],
2886                b_values: vec![opt_or_placeholder(&reg.created.1)],
2887                matches: eq_opt_str_trimmed(&reg.created.0, &reg.created.1),
2888            },
2889            DiffRow {
2890                label: "Expires",
2891                a_values: vec![opt_or_placeholder(&reg.expires.0)],
2892                b_values: vec![opt_or_placeholder(&reg.expires.1)],
2893                matches: eq_opt_str_trimmed(&reg.expires.0, &reg.expires.1),
2894            },
2895        ],
2896    };
2897
2898    let dns_section = DiffSection {
2899        title: "DNS",
2900        rows: vec![
2901            DiffRow {
2902                label: "Resolves",
2903                a_values: vec![bool_as_str(dns.resolves.0)],
2904                b_values: vec![bool_as_str(dns.resolves.1)],
2905                matches: dns.resolves.0 == dns.resolves.1,
2906            },
2907            DiffRow {
2908                label: "A Records",
2909                a_values: list_or_placeholder(&dns.a_records.0),
2910                b_values: list_or_placeholder(&dns.a_records.1),
2911                matches: eq_as_set(&dns.a_records.0, &dns.a_records.1),
2912            },
2913            DiffRow {
2914                label: "Nameservers",
2915                a_values: list_or_placeholder(&dns.nameservers.0),
2916                b_values: list_or_placeholder(&dns.nameservers.1),
2917                matches: eq_as_set(&dns.nameservers.0, &dns.nameservers.1),
2918            },
2919        ],
2920    };
2921
2922    let ssl_section = DiffSection {
2923        title: "SSL",
2924        rows: vec![
2925            DiffRow {
2926                label: "Issuer",
2927                a_values: vec![opt_or_placeholder(&ssl.issuer.0)],
2928                b_values: vec![opt_or_placeholder(&ssl.issuer.1)],
2929                matches: eq_opt_str_trimmed(&ssl.issuer.0, &ssl.issuer.1),
2930            },
2931            DiffRow {
2932                label: "Valid Until",
2933                a_values: vec![opt_or_placeholder(&ssl.valid_until.0)],
2934                b_values: vec![opt_or_placeholder(&ssl.valid_until.1)],
2935                matches: eq_opt_str_trimmed(&ssl.valid_until.0, &ssl.valid_until.1),
2936            },
2937            DiffRow {
2938                label: "Days Remaining",
2939                a_values: vec![opt_i64_or_placeholder(&ssl.days_remaining.0)],
2940                b_values: vec![opt_i64_or_placeholder(&ssl.days_remaining.1)],
2941                matches: ssl.days_remaining.0 == ssl.days_remaining.1,
2942            },
2943            DiffRow {
2944                label: "Valid",
2945                a_values: vec![opt_bool_or_placeholder(&ssl.is_valid.0)],
2946                b_values: vec![opt_bool_or_placeholder(&ssl.is_valid.1)],
2947                matches: ssl.is_valid.0 == ssl.is_valid.1,
2948            },
2949        ],
2950    };
2951
2952    vec![registration, dns_section, ssl_section]
2953}
2954
2955/// Hard cap on a single value column (applies before wrapping).
2956const DIFF_COLUMN_CAP: usize = 40;
2957
2958/// Computes the shared width for both value columns: the widest item across
2959/// every row of every section plus the two domain-header lengths, capped at
2960/// `DIFF_COLUMN_CAP`.
2961fn compute_column_width(sections: &[DiffSection], domain_a: &str, domain_b: &str) -> usize {
2962    let mut widest = domain_a.chars().count().max(domain_b.chars().count());
2963    for section in sections {
2964        for row in &section.rows {
2965            for v in row.a_values.iter().chain(row.b_values.iter()) {
2966                widest = widest.max(v.chars().count());
2967            }
2968        }
2969    }
2970    widest.clamp(1, DIFF_COLUMN_CAP)
2971}
2972
2973/// Right-pads `text` with spaces to `width` display chars. Never truncates.
2974fn pad_right(text: &str, width: usize) -> String {
2975    let have = text.chars().count();
2976    if have >= width {
2977        text.to_string()
2978    } else {
2979        format!("{}{}", text, " ".repeat(width - have))
2980    }
2981}
2982
2983#[cfg(test)]
2984mod tests {
2985    use super::*;
2986    use crate::diff::{DnsDiff, DomainDiff, RegistrationDiff, SslDiff};
2987
2988    fn formatter() -> HumanFormatter {
2989        HumanFormatter::new().without_colors()
2990    }
2991
2992    #[test]
2993    fn expired_shows_days_ago() {
2994        let f = formatter();
2995        let out = f.format_expiry_status("2024-01-01", -3);
2996        assert!(out.contains("expired 3 days ago"), "got: {}", out);
2997        assert!(!out.contains("-3"), "got: {}", out);
2998    }
2999
3000    #[test]
3001    fn expiring_soon_shows_expires_in() {
3002        let f = formatter();
3003        let out = f.format_expiry_status("2026-05-01", 15);
3004        assert!(out.contains("expires in 15 days"), "got: {}", out);
3005        assert!(!out.contains("days ago"), "got: {}", out);
3006    }
3007
3008    #[test]
3009    fn warning_window_uses_expires_in() {
3010        let f = formatter();
3011        let out = f.format_expiry_status("2026-07-01", 60);
3012        assert!(out.contains("expires in 60 days"), "got: {}", out);
3013        assert!(!out.contains("!"), "got: {}", out);
3014    }
3015
3016    #[test]
3017    fn healthy_expiry_uses_expires_in() {
3018        let f = formatter();
3019        let out = f.format_expiry_status("2027-01-01", 300);
3020        assert!(out.contains("expires in 300 days"), "got: {}", out);
3021        assert!(!out.contains("!"), "got: {}", out);
3022    }
3023
3024    #[test]
3025    fn expired_one_day_is_pluralized_simply() {
3026        // We don't singularize; verify the raw format.
3027        let f = formatter();
3028        let out = f.format_expiry_status("2024-01-01", -1);
3029        assert!(out.contains("expired 1 days ago"), "got: {}", out);
3030    }
3031
3032    #[test]
3033    fn boundary_30_days_is_warning_not_error() {
3034        let f = formatter();
3035        // 30 days -> not <30, so warning branch, no "!"
3036        let out = f.format_expiry_status("2026-05-15", 30);
3037        assert!(out.contains("expires in 30 days"), "got: {}", out);
3038        assert!(!out.contains("!"), "got: {}", out);
3039    }
3040
3041    #[test]
3042    fn eq_opt_str_trims_whitespace() {
3043        assert!(eq_opt_str_trimmed(
3044            &Some("  foo  ".to_string()),
3045            &Some("foo".to_string())
3046        ));
3047        assert!(!eq_opt_str_trimmed(
3048            &Some("foo".to_string()),
3049            &Some("bar".to_string())
3050        ));
3051    }
3052
3053    #[test]
3054    fn eq_opt_str_both_none_matches() {
3055        assert!(eq_opt_str_trimmed(&None, &None));
3056    }
3057
3058    #[test]
3059    fn eq_opt_str_empty_string_is_none() {
3060        assert!(eq_opt_str_trimmed(&None, &Some("".to_string())));
3061        assert!(eq_opt_str_trimmed(&Some("   ".to_string()), &None));
3062    }
3063
3064    #[test]
3065    fn eq_opt_str_some_vs_none_differs() {
3066        assert!(!eq_opt_str_trimmed(&Some("foo".to_string()), &None));
3067    }
3068
3069    #[test]
3070    fn eq_as_set_order_independent() {
3071        let a = vec!["ns1".to_string(), "ns2".to_string()];
3072        let b = vec!["ns2".to_string(), "ns1".to_string()];
3073        assert!(eq_as_set(&a, &b));
3074    }
3075
3076    #[test]
3077    fn eq_as_set_trims_and_drops_empty() {
3078        let a = vec!["ns1".to_string(), "  ".to_string(), " ns2 ".to_string()];
3079        let b = vec!["ns2".to_string(), "ns1".to_string()];
3080        assert!(eq_as_set(&a, &b));
3081    }
3082
3083    #[test]
3084    fn eq_as_set_different_contents() {
3085        let a = vec!["1.2.3.4".to_string()];
3086        let b = vec!["1.2.3.5".to_string()];
3087        assert!(!eq_as_set(&a, &b));
3088    }
3089
3090    #[test]
3091    fn eq_as_set_both_empty_matches() {
3092        let a: Vec<String> = vec![];
3093        let b: Vec<String> = vec![];
3094        assert!(eq_as_set(&a, &b));
3095    }
3096
3097    #[test]
3098    fn wrap_cell_short_returns_single_line() {
3099        assert_eq!(wrap_cell("hello", 10), vec!["hello".to_string()]);
3100    }
3101
3102    #[test]
3103    fn wrap_cell_wraps_at_word_boundary() {
3104        let out = wrap_cell("the quick brown fox", 10);
3105        assert_eq!(out, vec!["the quick".to_string(), "brown fox".to_string()]);
3106    }
3107
3108    #[test]
3109    fn wrap_cell_hard_breaks_when_no_whitespace() {
3110        // A long nameserver with no break points must hard-break at the cap.
3111        let out = wrap_cell("a.very.long.nameserver.example", 10);
3112        assert_eq!(
3113            out,
3114            vec![
3115                "a.very.lon".to_string(),
3116                "g.nameserv".to_string(),
3117                "er.example".to_string(),
3118            ]
3119        );
3120    }
3121
3122    #[test]
3123    fn wrap_cell_exact_width_no_wrap() {
3124        assert_eq!(wrap_cell("1234567890", 10), vec!["1234567890".to_string()]);
3125    }
3126
3127    #[test]
3128    fn wrap_cell_empty_input_returns_one_empty_line() {
3129        assert_eq!(wrap_cell("", 10), vec!["".to_string()]);
3130    }
3131
3132    #[test]
3133    fn wrap_cell_zero_width_treated_as_one() {
3134        // Defensive: never loop forever on width 0. Caller must not pass 0, but
3135        // we clamp to 1 to be safe.
3136        let out = wrap_cell("abc", 0);
3137        assert_eq!(out, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
3138    }
3139
3140    fn make_sample_diff() -> DomainDiff {
3141        DomainDiff {
3142            domain_a: "example.com".to_string(),
3143            domain_b: "google.com".to_string(),
3144            registration: RegistrationDiff {
3145                registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
3146                organization: (None, Some("Google LLC".to_string())),
3147                created: (
3148                    Some("1995-08-14".to_string()),
3149                    Some("1997-09-15".to_string()),
3150                ),
3151                expires: (
3152                    Some("2026-08-13".to_string()),
3153                    Some("2028-09-14".to_string()),
3154                ),
3155            },
3156            dns: DnsDiff {
3157                a_records: (
3158                    vec!["93.184.216.34".to_string()],
3159                    vec!["142.250.185.46".to_string()],
3160                ),
3161                nameservers: (
3162                    vec!["ns1.example".to_string(), "ns2.example".to_string()],
3163                    vec!["ns2.example".to_string(), "ns1.example".to_string()],
3164                ),
3165                resolves: (true, true),
3166            },
3167            ssl: SslDiff {
3168                issuer: (
3169                    Some("DigiCert".to_string()),
3170                    Some("Google Trust".to_string()),
3171                ),
3172                valid_until: (
3173                    Some("2025-03-01".to_string()),
3174                    Some("2025-02-15".to_string()),
3175                ),
3176                days_remaining: (Some(89), Some(75)),
3177                is_valid: (Some(true), Some(true)),
3178            },
3179        }
3180    }
3181
3182    #[test]
3183    fn build_diff_sections_produces_three_sections() {
3184        let diff = make_sample_diff();
3185        let sections = build_diff_sections(&diff);
3186        assert_eq!(sections.len(), 3);
3187        assert_eq!(sections[0].title, "Registration");
3188        assert_eq!(sections[1].title, "DNS");
3189        assert_eq!(sections[2].title, "SSL");
3190    }
3191
3192    #[test]
3193    fn build_diff_sections_marks_nameservers_as_match_when_sets_equal() {
3194        let diff = make_sample_diff();
3195        let sections = build_diff_sections(&diff);
3196        let dns = &sections[1];
3197        let ns_row = dns.rows.iter().find(|r| r.label == "Nameservers").unwrap();
3198        assert!(ns_row.matches, "reversed-order nameservers should match");
3199    }
3200
3201    #[test]
3202    fn build_diff_sections_marks_registrar_differ() {
3203        let diff = make_sample_diff();
3204        let sections = build_diff_sections(&diff);
3205        let reg = &sections[0];
3206        let row = reg.rows.iter().find(|r| r.label == "Registrar").unwrap();
3207        assert!(!row.matches);
3208    }
3209
3210    #[test]
3211    fn build_diff_sections_marks_resolves_match_when_both_true() {
3212        let diff = make_sample_diff();
3213        let sections = build_diff_sections(&diff);
3214        let dns = &sections[1];
3215        let row = dns.rows.iter().find(|r| r.label == "Resolves").unwrap();
3216        assert!(row.matches);
3217        assert_eq!(row.a_values, vec!["yes".to_string()]);
3218        assert_eq!(row.b_values, vec!["yes".to_string()]);
3219    }
3220
3221    #[test]
3222    fn build_diff_sections_renders_none_as_em_dash() {
3223        let diff = make_sample_diff();
3224        let sections = build_diff_sections(&diff);
3225        let reg = &sections[0];
3226        let row = reg.rows.iter().find(|r| r.label == "Organization").unwrap();
3227        assert_eq!(row.a_values, vec!["—".to_string()]);
3228    }
3229
3230    #[test]
3231    fn build_diff_sections_a_records_one_item_per_row() {
3232        let mut diff = make_sample_diff();
3233        diff.dns.a_records = (
3234            vec!["1.1.1.1".to_string(), "2.2.2.2".to_string()],
3235            vec!["3.3.3.3".to_string()],
3236        );
3237        let sections = build_diff_sections(&diff);
3238        let dns = &sections[1];
3239        let row = dns.rows.iter().find(|r| r.label == "A Records").unwrap();
3240        assert_eq!(row.a_values.len(), 2);
3241        assert_eq!(row.b_values.len(), 1);
3242    }
3243
3244    #[test]
3245    fn build_diff_sections_preserves_field_order() {
3246        let diff = make_sample_diff();
3247        let sections = build_diff_sections(&diff);
3248        let labels: Vec<&str> = sections[0].rows.iter().map(|r| r.label).collect();
3249        assert_eq!(
3250            labels,
3251            vec!["Registrar", "Organization", "Created", "Expires"]
3252        );
3253        let dns_labels: Vec<&str> = sections[1].rows.iter().map(|r| r.label).collect();
3254        assert_eq!(dns_labels, vec!["Resolves", "A Records", "Nameservers"]);
3255        let ssl_labels: Vec<&str> = sections[2].rows.iter().map(|r| r.label).collect();
3256        assert_eq!(
3257            ssl_labels,
3258            vec!["Issuer", "Valid Until", "Days Remaining", "Valid"]
3259        );
3260    }
3261
3262    #[test]
3263    fn compute_column_width_uses_widest_value_across_sections() {
3264        let sections = vec![DiffSection {
3265            title: "Registration",
3266            rows: vec![
3267                DiffRow {
3268                    label: "Registrar",
3269                    a_values: vec!["IANA".to_string()],
3270                    b_values: vec!["MarkMonitor".to_string()],
3271                    matches: false,
3272                },
3273                DiffRow {
3274                    label: "Organization",
3275                    a_values: vec!["—".to_string()],
3276                    b_values: vec!["Google LLC".to_string()],
3277                    matches: false,
3278                },
3279            ],
3280        }];
3281        // Widest item is "MarkMonitor" (11).
3282        assert_eq!(compute_column_width(&sections, "a.com", "b.com"), 11);
3283    }
3284
3285    #[test]
3286    fn compute_column_width_respects_domain_width() {
3287        let sections = vec![DiffSection {
3288            title: "Registration",
3289            rows: vec![DiffRow {
3290                label: "Registrar",
3291                a_values: vec!["x".to_string()],
3292                b_values: vec!["y".to_string()],
3293                matches: false,
3294            }],
3295        }];
3296        // Domain "very-long-domain.example" is wider than any value.
3297        let w = compute_column_width(&sections, "very-long-domain.example", "b.com");
3298        assert_eq!(w, "very-long-domain.example".chars().count());
3299    }
3300
3301    #[test]
3302    fn compute_column_width_caps_at_40() {
3303        let long_value = "x".repeat(100);
3304        let sections = vec![DiffSection {
3305            title: "Registration",
3306            rows: vec![DiffRow {
3307                label: "Registrar",
3308                a_values: vec![long_value],
3309                b_values: vec!["y".to_string()],
3310                matches: false,
3311            }],
3312        }];
3313        assert_eq!(compute_column_width(&sections, "a.com", "b.com"), 40);
3314    }
3315
3316    #[test]
3317    fn compute_column_width_minimum_sensible_default() {
3318        // Even with tiny inputs, width should not be zero.
3319        let sections = vec![DiffSection {
3320            title: "Registration",
3321            rows: vec![DiffRow {
3322                label: "X",
3323                a_values: vec!["a".to_string()],
3324                b_values: vec!["b".to_string()],
3325                matches: true,
3326            }],
3327        }];
3328        let w = compute_column_width(&sections, "a", "b");
3329        assert!(w >= 1);
3330    }
3331
3332    fn diff_formatter() -> HumanFormatter {
3333        HumanFormatter::new().without_colors()
3334    }
3335
3336    #[test]
3337    fn format_diff_shows_column_headers_with_domain_names() {
3338        let f = diff_formatter();
3339        let out = f.format_diff(&make_sample_diff());
3340        assert!(
3341            out.contains("example.com"),
3342            "missing domain_a in output:\n{}",
3343            out
3344        );
3345        assert!(
3346            out.contains("google.com"),
3347            "missing domain_b in output:\n{}",
3348            out
3349        );
3350        // A row indicating the header underline (Unicode box-drawing dash).
3351        assert!(out.contains("──"), "missing header underline:\n{}", out);
3352    }
3353
3354    #[test]
3355    fn format_diff_marks_differing_rows_with_neq() {
3356        let f = diff_formatter();
3357        let out = f.format_diff(&make_sample_diff());
3358        // Registrar differs: IANA vs MarkMonitor.
3359        let registrar_line = out
3360            .lines()
3361            .find(|l| l.contains("Registrar"))
3362            .expect("registrar line missing");
3363        assert!(
3364            registrar_line.contains("≠"),
3365            "registrar row should be marked differ: {}",
3366            registrar_line
3367        );
3368    }
3369
3370    #[test]
3371    fn format_diff_marks_matching_rows_with_eq() {
3372        let f = diff_formatter();
3373        let out = f.format_diff(&make_sample_diff());
3374        // Resolves matches (both true).
3375        let resolves_line = out
3376            .lines()
3377            .find(|l| l.contains("Resolves"))
3378            .expect("resolves line missing");
3379        assert!(
3380            resolves_line.contains('='),
3381            "resolves row should be marked match: {}",
3382            resolves_line
3383        );
3384        assert!(!resolves_line.contains('≠'));
3385    }
3386
3387    #[test]
3388    fn format_diff_nameservers_reversed_order_is_match() {
3389        // make_sample_diff has reversed nameservers.
3390        let f = diff_formatter();
3391        let out = f.format_diff(&make_sample_diff());
3392        let ns_line = out
3393            .lines()
3394            .find(|l| l.contains("Nameservers"))
3395            .expect("nameservers line missing");
3396        assert!(
3397            ns_line.contains('=') && !ns_line.contains('≠'),
3398            "nameservers row should match (set equality): {}",
3399            ns_line
3400        );
3401    }
3402
3403    #[test]
3404    fn format_diff_organization_none_renders_em_dash() {
3405        let f = diff_formatter();
3406        let out = f.format_diff(&make_sample_diff());
3407        let org_line = out
3408            .lines()
3409            .find(|l| l.contains("Organization"))
3410            .expect("organization line missing");
3411        assert!(org_line.contains("—"), "expected em dash: {}", org_line);
3412    }
3413
3414    #[test]
3415    fn format_diff_multi_value_a_records_one_per_line() {
3416        let mut diff = make_sample_diff();
3417        diff.dns.a_records = (
3418            vec!["1.1.1.1".to_string(), "2.2.2.2".to_string()],
3419            vec!["3.3.3.3".to_string(), "4.4.4.4".to_string()],
3420        );
3421        let f = diff_formatter();
3422        let out = f.format_diff(&diff);
3423        assert!(out.contains("1.1.1.1"), "missing 1.1.1.1:\n{}", out);
3424        assert!(out.contains("2.2.2.2"), "missing 2.2.2.2:\n{}", out);
3425        assert!(out.contains("3.3.3.3"), "missing 3.3.3.3:\n{}", out);
3426        assert!(out.contains("4.4.4.4"), "missing 4.4.4.4:\n{}", out);
3427        // The label only appears on the first row of the field.
3428        let a_records_label_count = out.matches("A Records").count();
3429        assert_eq!(
3430            a_records_label_count, 1,
3431            "A Records label should appear exactly once:\n{}",
3432            out
3433        );
3434    }
3435
3436    #[test]
3437    fn format_diff_wraps_long_scalar_values() {
3438        let mut diff = make_sample_diff();
3439        let long = "a".repeat(60);
3440        diff.ssl.issuer = (Some(long.clone()), Some("short".to_string()));
3441        let f = diff_formatter();
3442        let out = f.format_diff(&diff);
3443        // The 60-char value should not appear on any single line in full.
3444        for line in out.lines() {
3445            assert!(
3446                !line.contains(&long),
3447                "unwrapped 60-char value on one line: {}",
3448                line
3449            );
3450        }
3451        // But the characters as a whole should still be present.
3452        let chars_present = out.matches('a').count();
3453        assert!(
3454            chars_present >= 60,
3455            "wrapped value should preserve all chars, got {}",
3456            chars_present
3457        );
3458    }
3459
3460    #[test]
3461    fn format_diff_plain_mode_contains_marker_glyphs() {
3462        let out = diff_formatter().format_diff(&make_sample_diff());
3463        assert!(out.contains('='), "plain mode missing =");
3464        assert!(out.contains('≠'), "plain mode missing ≠");
3465        assert!(out.contains('─'), "plain mode missing header rule ─");
3466    }
3467
3468    #[test]
3469    fn format_diff_all_matching_has_no_neq() {
3470        let diff = DomainDiff {
3471            domain_a: "a.com".to_string(),
3472            domain_b: "a.com".to_string(),
3473            registration: RegistrationDiff {
3474                registrar: (Some("X".to_string()), Some("X".to_string())),
3475                organization: (Some("Org".to_string()), Some("Org".to_string())),
3476                created: (Some("2020".to_string()), Some("2020".to_string())),
3477                expires: (Some("2030".to_string()), Some("2030".to_string())),
3478            },
3479            dns: DnsDiff {
3480                a_records: (vec!["1.1.1.1".to_string()], vec!["1.1.1.1".to_string()]),
3481                nameservers: (vec!["ns".to_string()], vec!["ns".to_string()]),
3482                resolves: (true, true),
3483            },
3484            ssl: SslDiff {
3485                issuer: (Some("I".to_string()), Some("I".to_string())),
3486                valid_until: (Some("2030".to_string()), Some("2030".to_string())),
3487                days_remaining: (Some(10), Some(10)),
3488                is_valid: (Some(true), Some(true)),
3489            },
3490        };
3491        let out = diff_formatter().format_diff(&diff);
3492        assert!(
3493            !out.contains('≠'),
3494            "all-match diff should have no ≠:\n{}",
3495            out
3496        );
3497    }
3498
3499    #[test]
3500    fn format_diff_all_differing_has_no_eq() {
3501        let diff = DomainDiff {
3502            domain_a: "a.com".to_string(),
3503            domain_b: "b.com".to_string(),
3504            registration: RegistrationDiff {
3505                registrar: (Some("X".to_string()), Some("Y".to_string())),
3506                organization: (Some("OrgX".to_string()), Some("OrgY".to_string())),
3507                created: (Some("2020".to_string()), Some("2021".to_string())),
3508                expires: (Some("2030".to_string()), Some("2031".to_string())),
3509            },
3510            dns: DnsDiff {
3511                a_records: (vec!["1.1.1.1".to_string()], vec!["2.2.2.2".to_string()]),
3512                nameservers: (vec!["nsa".to_string()], vec!["nsb".to_string()]),
3513                resolves: (true, false),
3514            },
3515            ssl: SslDiff {
3516                issuer: (Some("IA".to_string()), Some("IB".to_string())),
3517                valid_until: (Some("2030".to_string()), Some("2031".to_string())),
3518                days_remaining: (Some(10), Some(20)),
3519                is_valid: (Some(true), Some(false)),
3520            },
3521        };
3522        let out = diff_formatter().format_diff(&diff);
3523        // Every field differs. Match marker must not appear on any row.
3524        // We exclude the rule-line `─` and any spaces; only search within
3525        // lines that contain a field label (start with four spaces).
3526        for line in out.lines() {
3527            if line.starts_with("    ") && line.len() > 10 {
3528                assert!(
3529                    !line.contains('='),
3530                    "all-differing diff should have no = on field rows: {}",
3531                    line
3532                );
3533            }
3534        }
3535    }
3536
3537    #[test]
3538    fn format_diff_uneven_list_lengths_pad_shorter_side() {
3539        let mut diff = make_sample_diff();
3540        diff.dns.nameservers = (
3541            vec!["ns1".to_string(), "ns2".to_string(), "ns3".to_string()],
3542            vec!["only".to_string()],
3543        );
3544        let out = diff_formatter().format_diff(&diff);
3545        // All three left-side nameservers render.
3546        assert!(out.contains("ns1"), "ns1 missing:\n{}", out);
3547        assert!(out.contains("ns2"), "ns2 missing:\n{}", out);
3548        assert!(out.contains("ns3"), "ns3 missing:\n{}", out);
3549        // Right side only has one value, "only".
3550        assert!(out.contains("only"), "right-side 'only' missing:\n{}", out);
3551        // The "only" string must appear exactly once — it should not be
3552        // mistakenly duplicated onto padding rows.
3553        assert_eq!(
3554            out.matches("only").count(),
3555            1,
3556            "right-side value must appear exactly once:\n{}",
3557            out
3558        );
3559    }
3560
3561    fn availability_lookup(available: bool, confidence: &str) -> LookupResult {
3562        LookupResult::Available {
3563            data: Box::new(crate::availability::AvailabilityResult {
3564                domain: "myroyalcanin.lv".to_string(),
3565                available,
3566                confidence: confidence.to_string(),
3567                method: "whois".to_string(),
3568                details: None,
3569            }),
3570            rdap_error: "bootstrap failed".to_string(),
3571            whois_error: String::new(),
3572            whois_data: None,
3573        }
3574    }
3575
3576    #[test]
3577    fn format_lookup_registered_high_confidence_does_not_say_available() {
3578        // Regression: a `LookupResult::Available` with `available: false` was
3579        // previously rendered as "AVAILABLE" because the formatter branched on
3580        // `confidence` alone. See myroyalcanin.lv investigation.
3581        let f = formatter();
3582        let out = f.format_lookup(&availability_lookup(false, "high"));
3583        assert!(
3584            !out.contains("AVAILABLE"),
3585            "must not claim available:\n{}",
3586            out
3587        );
3588        assert!(
3589            out.contains("REGISTERED"),
3590            "must render REGISTERED:\n{}",
3591            out
3592        );
3593        assert!(
3594            out.contains("(registered)"),
3595            "header suffix must say registered:\n{}",
3596            out
3597        );
3598    }
3599
3600    #[test]
3601    fn format_lookup_available_high_confidence_still_says_available() {
3602        let f = formatter();
3603        let out = f.format_lookup(&availability_lookup(true, "high"));
3604        assert!(
3605            out.contains("AVAILABLE"),
3606            "high-confidence available:\n{}",
3607            out
3608        );
3609        assert!(out.contains("(available)"), "header suffix:\n{}", out);
3610    }
3611
3612    #[test]
3613    fn format_lookup_likely_registered_medium_confidence() {
3614        let f = formatter();
3615        let out = f.format_lookup(&availability_lookup(false, "medium"));
3616        assert!(out.contains("LIKELY REGISTERED"), "medium reg:\n{}", out);
3617        assert!(
3618            !out.contains("MAY BE AVAILABLE"),
3619            "must not say MAY BE AVAILABLE:\n{}",
3620            out
3621        );
3622    }
3623
3624    #[test]
3625    fn domain_info_verdict_registered_for_high_confidence_unavailable() {
3626        // Regression: DomainInfo::availability_verdict ignored data.available.
3627        let lookup = availability_lookup(false, "high");
3628        let info = crate::domain_info::DomainInfo::from_lookup_result(&lookup);
3629        assert_eq!(info.availability_verdict.as_deref(), Some("registered"));
3630    }
3631
3632    #[test]
3633    fn format_diff_em_dash_is_dim_not_row_color() {
3634        // Colored formatter — we need to see the ANSI escape codes.
3635        // Force color output even in non-TTY test environments.
3636        colored::control::set_override(true);
3637        let f = HumanFormatter::new();
3638        let out = f.format_diff(&make_sample_diff());
3639        colored::control::unset_override();
3640
3641        // In make_sample_diff, Organization is (None, Some("Google LLC")) →
3642        // differ row. The row-level red color for differ rows is the bright-red
3643        // ANSI code `\x1b[91m`. The dim em-dash cell should NOT appear inside
3644        // that code.
3645        //
3646        // We look for the Organization line and assert that the em-dash in it
3647        // is NOT wrapped in bright-red — i.e. the substring "\x1b[91m—" should
3648        // not appear. The em-dash should instead appear wrapped in the dim
3649        // (bright-black, `\x1b[90m`) code.
3650        let org_line = out
3651            .lines()
3652            .find(|l| l.contains("Organization"))
3653            .expect("organization line missing");
3654        assert!(
3655            !org_line.contains("\x1b[91m—"),
3656            "em-dash should not be red on a differ row: {:?}",
3657            org_line
3658        );
3659        assert!(
3660            org_line.contains("\x1b[90m"),
3661            "em-dash should be dim (bright-black ANSI): {:?}",
3662            org_line
3663        );
3664    }
3665}