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