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