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