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