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