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