Skip to main content

seer_core/output/
human.rs

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