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(&result.method)
1915 ));
1916 if let Some(ref details) = result.details {
1917 output.push(format!(
1918 " {}: {}",
1919 self.label("Details"),
1920 self.value(details)
1921 ));
1922 }
1923
1924 output.join("\n")
1925 }
1926
1927 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
1928 let mut output = Vec::new();
1929
1930 output.push(format!(
1931 "DNSSEC Report for {}",
1932 self.success(&sanitize_display(&report.domain))
1933 ));
1934 output.push(String::new());
1935
1936 let status_colored = match report.status.as_str() {
1937 "secure" => self.success(&report.status),
1938 "insecure" | "partial" => self.warning(&report.status),
1939 _ => self.error(&report.status),
1940 };
1941 output.push(format!(" {}: {}", self.label("Status"), status_colored));
1942 let chain_colored = if report.chain_valid {
1943 self.success("valid")
1944 } else if report.has_ds_records && report.has_dnskey_records {
1945 self.error("invalid")
1946 } else {
1947 self.warning("n/a")
1948 };
1949 output.push(format!(
1950 " {}: {}",
1951 self.label("Chain Valid"),
1952 chain_colored
1953 ));
1954 output.push(format!(
1955 " {}: {}",
1956 self.label("Enabled"),
1957 self.value(&report.enabled.to_string())
1958 ));
1959 output.push(format!(
1960 " {}: {}",
1961 self.label("DS Records"),
1962 self.value(&report.ds_records.len().to_string())
1963 ));
1964 output.push(format!(
1965 " {}: {}",
1966 self.label("DNSKEY Records"),
1967 self.value(&report.dnskey_records.len().to_string())
1968 ));
1969
1970 if !report.ds_records.is_empty() {
1971 output.push(String::new());
1972 output.push(format!(" {}:", self.label("DS Records")));
1973 for ds in &report.ds_records {
1974 let match_indicator = if ds.matched_key && ds.digest_verified {
1975 self.success("\u{2713} verified")
1976 } else if ds.matched_key {
1977 self.error("\u{2717} digest mismatch")
1978 } else {
1979 self.error("\u{2717} no matching key")
1980 };
1981 output.push(format!(
1982 " Key Tag: {}, Algorithm: {} ({}), Digest: {} ({}) [{}]",
1983 ds.key_tag,
1984 ds.algorithm,
1985 sanitize_display(&ds.algorithm_name),
1986 ds.digest_type,
1987 sanitize_display(&ds.digest_type_name),
1988 match_indicator,
1989 ));
1990 }
1991 }
1992
1993 if !report.dnskey_records.is_empty() {
1994 output.push(String::new());
1995 output.push(format!(" {}:", self.label("DNSKEY Records")));
1996 for key in &report.dnskey_records {
1997 let role = if key.is_ksk {
1998 "KSK"
1999 } else if key.is_zsk {
2000 "ZSK"
2001 } else {
2002 "Other"
2003 };
2004 output.push(format!(
2005 " Key Tag: {}, Flags: {}, Role: {}, Algorithm: {} ({})",
2006 key.key_tag,
2007 key.flags,
2008 role,
2009 key.algorithm,
2010 sanitize_display(&key.algorithm_name)
2011 ));
2012 }
2013 }
2014
2015 if !report.issues.is_empty() {
2016 output.push(String::new());
2017 output.push(format!(" {}:", self.label("Issues")));
2018 for issue in &report.issues {
2019 output.push(format!(" - {}", sanitize_display(issue)));
2020 }
2021 }
2022
2023 output.join("\n")
2024 }
2025
2026 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
2027 let mut output = Vec::new();
2028
2029 output.push(self.header(&format!("TLD Info: .{}", info.tld)));
2030
2031 output.push(format!(
2032 " {}: {}",
2033 self.label("Type"),
2034 self.value(&info.tld_type)
2035 ));
2036
2037 if let Some(ref server) = info.whois_server {
2038 output.push(format!(
2039 " {}: {}",
2040 self.label("WHOIS Server"),
2041 self.value(server)
2042 ));
2043 } else {
2044 output.push(format!(
2045 " {}: {}",
2046 self.label("WHOIS Server"),
2047 self.warning("not available")
2048 ));
2049 }
2050
2051 if let Some(ref url) = info.rdap_url {
2052 output.push(format!(" {}: {}", self.label("RDAP URL"), self.value(url)));
2053 } else {
2054 output.push(format!(
2055 " {}: {}",
2056 self.label("RDAP URL"),
2057 self.warning("not available")
2058 ));
2059 }
2060
2061 if let Some(ref url) = info.registry_url {
2062 output.push(format!(" {}: {}", self.label("Registry"), self.value(url)));
2063 } else {
2064 output.push(format!(
2065 " {}: {}",
2066 self.label("Registry"),
2067 self.warning("not available")
2068 ));
2069 }
2070
2071 output.join("\n")
2072 }
2073
2074 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
2075 let mut output = Vec::new();
2076
2077 output.push(self.header(&format!(
2078 "DNS Comparison: {} {}",
2079 comparison.domain, comparison.record_type
2080 )));
2081
2082 if comparison.matches {
2084 output.push(format!(" {} Records match", self.success("✓")));
2085 } else {
2086 output.push(format!(" {} Records differ", self.error("✗")));
2087 }
2088 output.push(String::new());
2089
2090 if let Some(ref err) = comparison.server_a.error {
2092 output.push(format!(
2093 " {} ({}): {}",
2094 self.label("Server A"),
2095 self.value(&sanitize_display(&comparison.server_a.nameserver)),
2096 self.error(&sanitize_display(err))
2097 ));
2098 } else {
2099 output.push(format!(
2100 " {} ({}): {} records",
2101 self.label("Server A"),
2102 self.value(&sanitize_display(&comparison.server_a.nameserver)),
2103 self.value(&comparison.server_a.records.len().to_string())
2104 ));
2105 for record in &comparison.server_a.records {
2106 output.push(format!(
2107 " - {}",
2108 self.value(&sanitize_display(&record.format_short()))
2109 ));
2110 }
2111 }
2112 output.push(String::new());
2113
2114 if let Some(ref err) = comparison.server_b.error {
2116 output.push(format!(
2117 " {} ({}): {}",
2118 self.label("Server B"),
2119 self.value(&sanitize_display(&comparison.server_b.nameserver)),
2120 self.error(&sanitize_display(err))
2121 ));
2122 } else {
2123 output.push(format!(
2124 " {} ({}): {} records",
2125 self.label("Server B"),
2126 self.value(&sanitize_display(&comparison.server_b.nameserver)),
2127 self.value(&comparison.server_b.records.len().to_string())
2128 ));
2129 for record in &comparison.server_b.records {
2130 output.push(format!(
2131 " - {}",
2132 self.value(&sanitize_display(&record.format_short()))
2133 ));
2134 }
2135 }
2136 output.push(String::new());
2137
2138 output.push(format!(
2140 " {}: {}",
2141 self.label("Common"),
2142 if comparison.common.is_empty() {
2143 self.warning("(none)")
2144 } else {
2145 self.value(&sanitize_display(&comparison.common.join(", ")))
2146 }
2147 ));
2148
2149 output.push(format!(
2151 " {}: {}",
2152 self.label(&format!(
2153 "Only in {}",
2154 sanitize_display(&comparison.server_a.nameserver)
2155 )),
2156 if comparison.only_in_a.is_empty() {
2157 self.warning("(none)")
2158 } else {
2159 self.error(&sanitize_display(&comparison.only_in_a.join(", ")))
2160 }
2161 ));
2162
2163 output.push(format!(
2165 " {}: {}",
2166 self.label(&format!(
2167 "Only in {}",
2168 sanitize_display(&comparison.server_b.nameserver)
2169 )),
2170 if comparison.only_in_b.is_empty() {
2171 self.warning("(none)")
2172 } else {
2173 self.error(&sanitize_display(&comparison.only_in_b.join(", ")))
2174 }
2175 ));
2176
2177 output.join("\n")
2178 }
2179
2180 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
2181 let mut output = Vec::new();
2182
2183 output.push(self.header(&format!("Subdomains: {}", sanitize_display(&result.domain))));
2184
2185 output.push(format!(
2186 " {}: {}",
2187 self.label("Source"),
2188 self.value(&sanitize_display(&result.source))
2189 ));
2190 output.push(format!(
2191 " {}: {}",
2192 self.label("Count"),
2193 self.value(&result.count.to_string())
2194 ));
2195
2196 if result.subdomains.is_empty() {
2197 output.push(format!(" {}", self.warning("No subdomains found")));
2198 } else {
2199 output.push(String::new());
2200 for subdomain in &result.subdomains {
2201 output.push(format!(
2202 " - {}",
2203 self.value(&sanitize_display(subdomain))
2204 ));
2205 }
2206 }
2207
2208 output.join("\n")
2209 }
2210
2211 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
2212 let mut output = Vec::new();
2213
2214 output.push(self.header(&format!(
2215 "Diff: {} vs {}",
2216 sanitize_display(&diff.domain_a),
2217 sanitize_display(&diff.domain_b)
2218 )));
2219
2220 let domain_a = sanitize_display(&diff.domain_a);
2221 let domain_b = sanitize_display(&diff.domain_b);
2222 let sections = build_diff_sections(diff);
2223 let col_width = compute_column_width(§ions, &domain_a, &domain_b);
2224
2225 let label_width = sections
2227 .iter()
2228 .flat_map(|s| s.rows.iter().map(|r| r.label.chars().count()))
2229 .max()
2230 .unwrap_or(0);
2231
2232 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;
2240
2241 let header_line = format!(
2243 "{}{} {}",
2244 " ".repeat(header_left_pad),
2245 self.label(&pad_right(&domain_a, col_width)),
2246 self.label(&domain_b)
2247 );
2248 let rule_a: String = "─".repeat(domain_a.chars().count());
2249 let rule_b: String = "─".repeat(domain_b.chars().count());
2250 let rule_line = format!(
2251 "{}{} {}",
2252 " ".repeat(header_left_pad),
2253 self.label(&pad_right(&rule_a, col_width)),
2254 self.label(&rule_b)
2255 );
2256 output.push(String::new());
2257 output.push(header_line);
2258 output.push(rule_line);
2259
2260 for section in §ions {
2261 output.push(String::new());
2262 output.push(format!("{}{}", section_indent, self.label(section.title)));
2263
2264 for row in §ion.rows {
2265 let mut a_lines: Vec<String> = row
2267 .a_values
2268 .iter()
2269 .flat_map(|v| wrap_cell(&sanitize_display(v), col_width))
2270 .collect();
2271 let mut b_lines: Vec<String> = row
2272 .b_values
2273 .iter()
2274 .flat_map(|v| wrap_cell(&sanitize_display(v), col_width))
2275 .collect();
2276
2277 let rows_needed = a_lines.len().max(b_lines.len()).max(1);
2279 while a_lines.len() < rows_needed {
2280 a_lines.push(String::new());
2281 }
2282 while b_lines.len() < rows_needed {
2283 b_lines.push(String::new());
2284 }
2285
2286 let marker_glyph = if row.matches { "=" } else { "≠" };
2287 let color = |s: &str| -> String {
2288 if row.matches {
2289 self.success(s)
2290 } else {
2291 self.error(s)
2292 }
2293 };
2294
2295 for (i, (a, b)) in a_lines.iter().zip(b_lines.iter()).enumerate() {
2296 let label_cell = if i == 0 {
2297 format!("{}{}", label_indent, pad_right(row.label, label_width))
2298 } else {
2299 format!("{}{}", label_indent, " ".repeat(label_width))
2300 };
2301 let marker_cell = if i == 0 {
2302 format!("{} ", color(marker_glyph))
2303 } else {
2304 " ".to_string()
2305 };
2306 let color_value = |s: &str, raw: &str| -> String {
2307 if raw.trim() == EMPTY_PLACEHOLDER {
2308 self.dim(s)
2309 } else {
2310 color(s)
2311 }
2312 };
2313 let a_cell = color_value(&pad_right(a, col_width), a);
2314 let b_cell = color_value(b, b);
2315 output.push(format!(
2316 "{} {}{} {}",
2317 self.label(&label_cell),
2318 marker_cell,
2319 a_cell,
2320 b_cell
2321 ));
2322 }
2323 }
2324 }
2325
2326 output.join("\n")
2327 }
2328
2329 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
2330 let mut output = Vec::new();
2331
2332 output.push(self.header(&format!("SSL Report: {}", sanitize_display(&report.domain))));
2333
2334 output.push(format!(
2335 " {}: {}",
2336 self.label("Valid"),
2337 if report.is_valid {
2338 self.success("yes")
2339 } else {
2340 self.error("no")
2341 }
2342 ));
2343 output.push(format!(
2344 " {}: {}",
2345 self.label("Days Until Expiry"),
2346 self.value(&report.days_until_expiry.to_string())
2347 ));
2348
2349 if let Some(ref proto) = report.protocol_version {
2350 output.push(format!(
2351 " {}: {}",
2352 self.label("Protocol"),
2353 self.value(&sanitize_display(proto))
2354 ));
2355 }
2356
2357 if !report.san_names.is_empty() {
2358 let sanitized_sans: Vec<String> = report
2359 .san_names
2360 .iter()
2361 .map(|s| sanitize_display(s))
2362 .collect();
2363 output.push(format!(
2364 " {}: {}",
2365 self.label("SANs"),
2366 self.value(&sanitized_sans.join(", "))
2367 ));
2368 }
2369
2370 if !report.chain.is_empty() {
2371 output.push(String::new());
2372 output.push(format!(" {}:", self.label("Certificate Chain")));
2373 for (i, cert) in report.chain.iter().enumerate() {
2374 output.push(format!(
2375 " [{}] {}",
2376 i,
2377 self.value(&sanitize_display(&cert.subject))
2378 ));
2379 output.push(format!(
2380 " {}: {}",
2381 self.label("Issuer"),
2382 self.value(&sanitize_display(&cert.issuer))
2383 ));
2384 if let Some(ref alg) = cert.signature_algorithm {
2385 output.push(format!(
2386 " {}: {}",
2387 self.label("Algorithm"),
2388 self.value(&sanitize_display(alg))
2389 ));
2390 }
2391 if let Some(ref key_type) = cert.key_type {
2392 let key_info = if let Some(bits) = cert.key_bits {
2393 format!("{} ({} bits)", sanitize_display(key_type), bits)
2394 } else {
2395 sanitize_display(key_type)
2396 };
2397 output.push(format!(
2398 " {}: {}",
2399 self.label("Key"),
2400 self.value(&key_info)
2401 ));
2402 }
2403 output.push(format!(
2404 " {}: {} to {}",
2405 self.label("Validity"),
2406 self.value(&cert.valid_from.format("%Y-%m-%d").to_string()),
2407 self.value(&cert.valid_until.format("%Y-%m-%d").to_string())
2408 ));
2409 }
2410 }
2411
2412 if let Some(ref caa) = report.caa {
2413 output.extend(self.render_caa_block(caa, " "));
2414 self.push_caa_note_footer(&mut output, caa);
2415 }
2416
2417 output.join("\n")
2418 }
2419
2420 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
2421 let mut output = Vec::new();
2422
2423 output.push(self.header("Domain Watch Report"));
2424
2425 output.push(format!(
2426 " {}: {}",
2427 self.label("Checked"),
2428 self.value(
2429 &report
2430 .checked_at
2431 .format("%Y-%m-%d %H:%M:%S UTC")
2432 .to_string()
2433 )
2434 ));
2435 output.push(format!(
2436 " {}: {} domains, {} warnings",
2437 self.label("Total"),
2438 self.value(&report.total.to_string()),
2439 if report.warnings > 0 {
2440 self.warning(&report.warnings.to_string())
2441 } else {
2442 self.value(&report.warnings.to_string())
2443 }
2444 ));
2445
2446 for r in &report.results {
2447 output.push(String::new());
2448
2449 let icon = if r.issues.is_empty() {
2450 self.success("v")
2451 } else {
2452 self.warning("!")
2453 };
2454 output.push(format!(
2455 " {} {}",
2456 icon,
2457 self.value(&sanitize_display(&r.domain))
2458 ));
2459
2460 let ssl_str = r
2462 .ssl_days_remaining
2463 .map(|d| format!("{} days", d))
2464 .unwrap_or_else(|| "N/A".to_string());
2465 let dom_str = r
2466 .domain_days_remaining
2467 .map(|d| format!("{} days", d))
2468 .unwrap_or_else(|| "N/A".to_string());
2469 let http_str = r
2470 .http_status
2471 .map(|s| s.to_string())
2472 .unwrap_or_else(|| "N/A".to_string());
2473
2474 output.push(format!(
2475 " {}: {} | {}: {} | {}: {}",
2476 self.label("SSL"),
2477 self.value(&ssl_str),
2478 self.label("Domain"),
2479 self.value(&dom_str),
2480 self.label("HTTP"),
2481 self.value(&http_str)
2482 ));
2483
2484 if !r.issues.is_empty() {
2485 output.push(format!(" {}:", self.label("Issues")));
2486 for issue in &r.issues {
2487 output.push(format!(
2488 " - {}",
2489 self.warning(&sanitize_display(issue))
2490 ));
2491 }
2492 }
2493 }
2494
2495 output.join("\n")
2496 }
2497
2498 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
2499 let mut output = Vec::new();
2500
2501 let source_str = match info.source {
2502 crate::domain_info::DomainInfoSource::Both => "both",
2503 crate::domain_info::DomainInfoSource::Rdap => "rdap",
2504 crate::domain_info::DomainInfoSource::Whois => "whois",
2505 crate::domain_info::DomainInfoSource::Available => "available",
2506 };
2507
2508 output.push(self.header(&format!(
2509 "Domain Info: {} (source: {})",
2510 sanitize_display(&info.domain),
2511 source_str
2512 )));
2513
2514 if let Some(verdict) = &info.availability_verdict {
2515 let colored = match verdict.as_str() {
2516 "available" => self.success("AVAILABLE"),
2517 "likely_available" => self.warning("MAY BE AVAILABLE"),
2518 "registered" => self.value("REGISTERED"),
2519 "likely_registered" => self.warning("LIKELY REGISTERED"),
2520 _ => self.error("UNKNOWN"),
2521 };
2522 output.push(format!(" {}: {}", self.label("Status"), colored));
2523 }
2524
2525 if let Some(ref registrar) = info.registrar {
2527 output.push(format!(
2528 " {}: {}",
2529 self.label("Registrar"),
2530 self.value(&sanitize_display(registrar))
2531 ));
2532 }
2533 if let Some(ref registrant) = info.registrant {
2534 output.push(format!(
2535 " {}: {}",
2536 self.label("Registrant"),
2537 self.value(&sanitize_display(registrant))
2538 ));
2539 }
2540 if let Some(ref organization) = info.organization {
2541 output.push(format!(
2542 " {}: {}",
2543 self.label("Organization"),
2544 self.value(&sanitize_display(organization))
2545 ));
2546 }
2547
2548 if let Some(ref created) = info.creation_date {
2550 output.push(format!(
2551 " {}: {}",
2552 self.label("Created"),
2553 self.value(&created.format("%Y-%m-%d").to_string())
2554 ));
2555 }
2556 if let Some(ref expires) = info.expiration_date {
2557 output.push(format!(
2558 " {}: {}",
2559 self.label("Expires"),
2560 self.value(&expires.format("%Y-%m-%d").to_string())
2561 ));
2562 }
2563 if let Some(ref updated) = info.updated_date {
2564 output.push(format!(
2565 " {}: {}",
2566 self.label("Updated"),
2567 self.value(&updated.format("%Y-%m-%d").to_string())
2568 ));
2569 }
2570
2571 if !info.nameservers.is_empty() {
2573 output.push(format!(
2574 " {}: {}",
2575 self.label("Nameservers"),
2576 self.value(&info.nameservers.join(", "))
2577 ));
2578 }
2579 if !info.status.is_empty() {
2580 output.push(format!(
2581 " {}: {}",
2582 self.label("Status"),
2583 self.value(&info.status.join(", "))
2584 ));
2585 }
2586 if let Some(ref dnssec) = info.dnssec {
2587 output.push(format!(
2588 " {}: {}",
2589 self.label("DNSSEC"),
2590 self.value(&sanitize_display(dnssec))
2591 ));
2592 }
2593
2594 let has_registrant_contact = info.registrant_email.is_some()
2596 || info.registrant_phone.is_some()
2597 || info.registrant_address.is_some()
2598 || info.registrant_country.is_some();
2599 if has_registrant_contact {
2600 output.push(format!("\n {}:", self.label("Registrant Contact")));
2601 if let Some(ref email) = info.registrant_email {
2602 output.push(format!(
2603 " {}: {}",
2604 self.label("Email"),
2605 self.value(&sanitize_display(email))
2606 ));
2607 }
2608 if let Some(ref phone) = info.registrant_phone {
2609 output.push(format!(
2610 " {}: {}",
2611 self.label("Phone"),
2612 self.value(&sanitize_display(phone))
2613 ));
2614 }
2615 if let Some(ref address) = info.registrant_address {
2616 output.push(format!(
2617 " {}: {}",
2618 self.label("Address"),
2619 self.value(&sanitize_display(address))
2620 ));
2621 }
2622 if let Some(ref country) = info.registrant_country {
2623 output.push(format!(
2624 " {}: {}",
2625 self.label("Country"),
2626 self.value(&sanitize_display(country))
2627 ));
2628 }
2629 }
2630
2631 let has_admin_contact = info.admin_name.is_some()
2633 || info.admin_organization.is_some()
2634 || info.admin_email.is_some()
2635 || info.admin_phone.is_some();
2636 if has_admin_contact {
2637 output.push(format!("\n {}:", self.label("Admin Contact")));
2638 if let Some(ref name) = info.admin_name {
2639 output.push(format!(
2640 " {}: {}",
2641 self.label("Name"),
2642 self.value(&sanitize_display(name))
2643 ));
2644 }
2645 if let Some(ref org) = info.admin_organization {
2646 output.push(format!(
2647 " {}: {}",
2648 self.label("Organization"),
2649 self.value(&sanitize_display(org))
2650 ));
2651 }
2652 if let Some(ref email) = info.admin_email {
2653 output.push(format!(
2654 " {}: {}",
2655 self.label("Email"),
2656 self.value(&sanitize_display(email))
2657 ));
2658 }
2659 if let Some(ref phone) = info.admin_phone {
2660 output.push(format!(
2661 " {}: {}",
2662 self.label("Phone"),
2663 self.value(&sanitize_display(phone))
2664 ));
2665 }
2666 }
2667
2668 let has_tech_contact = info.tech_name.is_some()
2670 || info.tech_organization.is_some()
2671 || info.tech_email.is_some()
2672 || info.tech_phone.is_some();
2673 if has_tech_contact {
2674 output.push(format!("\n {}:", self.label("Tech Contact")));
2675 if let Some(ref name) = info.tech_name {
2676 output.push(format!(
2677 " {}: {}",
2678 self.label("Name"),
2679 self.value(&sanitize_display(name))
2680 ));
2681 }
2682 if let Some(ref org) = info.tech_organization {
2683 output.push(format!(
2684 " {}: {}",
2685 self.label("Organization"),
2686 self.value(&sanitize_display(org))
2687 ));
2688 }
2689 if let Some(ref email) = info.tech_email {
2690 output.push(format!(
2691 " {}: {}",
2692 self.label("Email"),
2693 self.value(&sanitize_display(email))
2694 ));
2695 }
2696 if let Some(ref phone) = info.tech_phone {
2697 output.push(format!(
2698 " {}: {}",
2699 self.label("Phone"),
2700 self.value(&sanitize_display(phone))
2701 ));
2702 }
2703 }
2704
2705 let has_metadata = info.whois_server.is_some() || info.rdap_url.is_some();
2707 if has_metadata {
2708 output.push(format!("\n {}:", self.label("Protocol Metadata")));
2709 if let Some(ref whois_server) = info.whois_server {
2710 output.push(format!(
2711 " {}: {}",
2712 self.label("WHOIS Server"),
2713 self.value(&sanitize_display(whois_server))
2714 ));
2715 }
2716 if let Some(ref rdap_url) = info.rdap_url {
2717 output.push(format!(
2718 " {}: {}",
2719 self.label("RDAP URL"),
2720 self.value(&sanitize_display(rdap_url))
2721 ));
2722 }
2723 }
2724
2725 output.join("\n")
2726 }
2727}
2728
2729fn eq_opt_str_trimmed(a: &Option<String>, b: &Option<String>) -> bool {
2732 let norm = |o: &Option<String>| -> Option<String> {
2733 o.as_ref()
2734 .map(|s| s.trim().to_string())
2735 .filter(|s| !s.is_empty())
2736 };
2737 norm(a) == norm(b)
2738}
2739
2740fn eq_as_set(a: &[String], b: &[String]) -> bool {
2743 let mut an: Vec<String> = a
2744 .iter()
2745 .map(|s| s.trim().to_string())
2746 .filter(|s| !s.is_empty())
2747 .collect();
2748 let mut bn: Vec<String> = b
2749 .iter()
2750 .map(|s| s.trim().to_string())
2751 .filter(|s| !s.is_empty())
2752 .collect();
2753 an.sort();
2754 bn.sort();
2755 an == bn
2756}
2757
2758fn wrap_cell(text: &str, max_width: usize) -> Vec<String> {
2763 let width = max_width.max(1);
2764 if text.is_empty() {
2765 return vec![String::new()];
2766 }
2767
2768 let chars: Vec<char> = text.chars().collect();
2769 if chars.len() <= width {
2770 return vec![text.to_string()];
2771 }
2772
2773 let mut out = Vec::new();
2774 let mut i = 0;
2775 while i < chars.len() {
2776 let remaining = chars.len() - i;
2777 if remaining <= width {
2778 out.push(chars[i..].iter().collect());
2779 break;
2780 }
2781 let window_end = i + width;
2783 let break_at = (i..window_end).rev().find(|&k| chars[k].is_whitespace());
2784 match break_at {
2785 Some(k) if k > i => {
2786 out.push(chars[i..k].iter().collect());
2787 i = k + 1; }
2789 _ => {
2790 out.push(chars[i..window_end].iter().collect());
2792 i = window_end;
2793 }
2794 }
2795 }
2796
2797 out
2798}
2799
2800struct DiffRow {
2804 label: &'static str,
2805 a_values: Vec<String>,
2806 b_values: Vec<String>,
2807 matches: bool,
2808}
2809
2810struct DiffSection {
2811 title: &'static str,
2812 rows: Vec<DiffRow>,
2813}
2814
2815const EMPTY_PLACEHOLDER: &str = "—";
2817
2818fn opt_or_placeholder(o: &Option<String>) -> String {
2819 o.as_ref()
2820 .map(|s| s.trim().to_string())
2821 .filter(|s| !s.is_empty())
2822 .unwrap_or_else(|| EMPTY_PLACEHOLDER.to_string())
2823}
2824
2825fn opt_i64_or_placeholder(o: &Option<i64>) -> String {
2826 o.map(|n| n.to_string())
2827 .unwrap_or_else(|| EMPTY_PLACEHOLDER.to_string())
2828}
2829
2830fn opt_bool_or_placeholder(o: &Option<bool>) -> String {
2831 match o {
2832 Some(true) => "yes".to_string(),
2833 Some(false) => "no".to_string(),
2834 None => EMPTY_PLACEHOLDER.to_string(),
2835 }
2836}
2837
2838fn bool_as_str(b: bool) -> String {
2839 if b {
2840 "yes".to_string()
2841 } else {
2842 "no".to_string()
2843 }
2844}
2845
2846fn list_or_placeholder(list: &[String]) -> Vec<String> {
2847 let cleaned: Vec<String> = list
2848 .iter()
2849 .map(|s| s.trim().to_string())
2850 .filter(|s| !s.is_empty())
2851 .collect();
2852 if cleaned.is_empty() {
2853 vec![EMPTY_PLACEHOLDER.to_string()]
2854 } else {
2855 cleaned
2856 }
2857}
2858
2859fn build_diff_sections(diff: &crate::diff::DomainDiff) -> Vec<DiffSection> {
2860 let reg = &diff.registration;
2861 let dns = &diff.dns;
2862 let ssl = &diff.ssl;
2863
2864 let registration = DiffSection {
2865 title: "Registration",
2866 rows: vec![
2867 DiffRow {
2868 label: "Registrar",
2869 a_values: vec![opt_or_placeholder(®.registrar.0)],
2870 b_values: vec![opt_or_placeholder(®.registrar.1)],
2871 matches: eq_opt_str_trimmed(®.registrar.0, ®.registrar.1),
2872 },
2873 DiffRow {
2874 label: "Organization",
2875 a_values: vec![opt_or_placeholder(®.organization.0)],
2876 b_values: vec![opt_or_placeholder(®.organization.1)],
2877 matches: eq_opt_str_trimmed(®.organization.0, ®.organization.1),
2878 },
2879 DiffRow {
2880 label: "Created",
2881 a_values: vec![opt_or_placeholder(®.created.0)],
2882 b_values: vec![opt_or_placeholder(®.created.1)],
2883 matches: eq_opt_str_trimmed(®.created.0, ®.created.1),
2884 },
2885 DiffRow {
2886 label: "Expires",
2887 a_values: vec![opt_or_placeholder(®.expires.0)],
2888 b_values: vec![opt_or_placeholder(®.expires.1)],
2889 matches: eq_opt_str_trimmed(®.expires.0, ®.expires.1),
2890 },
2891 ],
2892 };
2893
2894 let dns_section = DiffSection {
2895 title: "DNS",
2896 rows: vec![
2897 DiffRow {
2898 label: "Resolves",
2899 a_values: vec![bool_as_str(dns.resolves.0)],
2900 b_values: vec![bool_as_str(dns.resolves.1)],
2901 matches: dns.resolves.0 == dns.resolves.1,
2902 },
2903 DiffRow {
2904 label: "A Records",
2905 a_values: list_or_placeholder(&dns.a_records.0),
2906 b_values: list_or_placeholder(&dns.a_records.1),
2907 matches: eq_as_set(&dns.a_records.0, &dns.a_records.1),
2908 },
2909 DiffRow {
2910 label: "Nameservers",
2911 a_values: list_or_placeholder(&dns.nameservers.0),
2912 b_values: list_or_placeholder(&dns.nameservers.1),
2913 matches: eq_as_set(&dns.nameservers.0, &dns.nameservers.1),
2914 },
2915 ],
2916 };
2917
2918 let ssl_section = DiffSection {
2919 title: "SSL",
2920 rows: vec![
2921 DiffRow {
2922 label: "Issuer",
2923 a_values: vec![opt_or_placeholder(&ssl.issuer.0)],
2924 b_values: vec![opt_or_placeholder(&ssl.issuer.1)],
2925 matches: eq_opt_str_trimmed(&ssl.issuer.0, &ssl.issuer.1),
2926 },
2927 DiffRow {
2928 label: "Valid Until",
2929 a_values: vec![opt_or_placeholder(&ssl.valid_until.0)],
2930 b_values: vec![opt_or_placeholder(&ssl.valid_until.1)],
2931 matches: eq_opt_str_trimmed(&ssl.valid_until.0, &ssl.valid_until.1),
2932 },
2933 DiffRow {
2934 label: "Days Remaining",
2935 a_values: vec![opt_i64_or_placeholder(&ssl.days_remaining.0)],
2936 b_values: vec![opt_i64_or_placeholder(&ssl.days_remaining.1)],
2937 matches: ssl.days_remaining.0 == ssl.days_remaining.1,
2938 },
2939 DiffRow {
2940 label: "Valid",
2941 a_values: vec![opt_bool_or_placeholder(&ssl.is_valid.0)],
2942 b_values: vec![opt_bool_or_placeholder(&ssl.is_valid.1)],
2943 matches: ssl.is_valid.0 == ssl.is_valid.1,
2944 },
2945 ],
2946 };
2947
2948 vec![registration, dns_section, ssl_section]
2949}
2950
2951const DIFF_COLUMN_CAP: usize = 40;
2953
2954fn compute_column_width(sections: &[DiffSection], domain_a: &str, domain_b: &str) -> usize {
2958 let mut widest = domain_a.chars().count().max(domain_b.chars().count());
2959 for section in sections {
2960 for row in §ion.rows {
2961 for v in row.a_values.iter().chain(row.b_values.iter()) {
2962 widest = widest.max(v.chars().count());
2963 }
2964 }
2965 }
2966 widest.clamp(1, DIFF_COLUMN_CAP)
2967}
2968
2969fn pad_right(text: &str, width: usize) -> String {
2971 let have = text.chars().count();
2972 if have >= width {
2973 text.to_string()
2974 } else {
2975 format!("{}{}", text, " ".repeat(width - have))
2976 }
2977}
2978
2979#[cfg(test)]
2980mod tests {
2981 use super::*;
2982 use crate::diff::{DnsDiff, DomainDiff, RegistrationDiff, SslDiff};
2983
2984 fn formatter() -> HumanFormatter {
2985 HumanFormatter::new().without_colors()
2986 }
2987
2988 #[test]
2989 fn expired_shows_days_ago() {
2990 let f = formatter();
2991 let out = f.format_expiry_status("2024-01-01", -3);
2992 assert!(out.contains("expired 3 days ago"), "got: {}", out);
2993 assert!(!out.contains("-3"), "got: {}", out);
2994 }
2995
2996 #[test]
2997 fn expiring_soon_shows_expires_in() {
2998 let f = formatter();
2999 let out = f.format_expiry_status("2026-05-01", 15);
3000 assert!(out.contains("expires in 15 days"), "got: {}", out);
3001 assert!(!out.contains("days ago"), "got: {}", out);
3002 }
3003
3004 #[test]
3005 fn warning_window_uses_expires_in() {
3006 let f = formatter();
3007 let out = f.format_expiry_status("2026-07-01", 60);
3008 assert!(out.contains("expires in 60 days"), "got: {}", out);
3009 assert!(!out.contains("!"), "got: {}", out);
3010 }
3011
3012 #[test]
3013 fn healthy_expiry_uses_expires_in() {
3014 let f = formatter();
3015 let out = f.format_expiry_status("2027-01-01", 300);
3016 assert!(out.contains("expires in 300 days"), "got: {}", out);
3017 assert!(!out.contains("!"), "got: {}", out);
3018 }
3019
3020 #[test]
3021 fn expired_one_day_is_pluralized_simply() {
3022 let f = formatter();
3024 let out = f.format_expiry_status("2024-01-01", -1);
3025 assert!(out.contains("expired 1 days ago"), "got: {}", out);
3026 }
3027
3028 #[test]
3029 fn boundary_30_days_is_warning_not_error() {
3030 let f = formatter();
3031 let out = f.format_expiry_status("2026-05-15", 30);
3033 assert!(out.contains("expires in 30 days"), "got: {}", out);
3034 assert!(!out.contains("!"), "got: {}", out);
3035 }
3036
3037 #[test]
3038 fn eq_opt_str_trims_whitespace() {
3039 assert!(eq_opt_str_trimmed(
3040 &Some(" foo ".to_string()),
3041 &Some("foo".to_string())
3042 ));
3043 assert!(!eq_opt_str_trimmed(
3044 &Some("foo".to_string()),
3045 &Some("bar".to_string())
3046 ));
3047 }
3048
3049 #[test]
3050 fn eq_opt_str_both_none_matches() {
3051 assert!(eq_opt_str_trimmed(&None, &None));
3052 }
3053
3054 #[test]
3055 fn eq_opt_str_empty_string_is_none() {
3056 assert!(eq_opt_str_trimmed(&None, &Some("".to_string())));
3057 assert!(eq_opt_str_trimmed(&Some(" ".to_string()), &None));
3058 }
3059
3060 #[test]
3061 fn eq_opt_str_some_vs_none_differs() {
3062 assert!(!eq_opt_str_trimmed(&Some("foo".to_string()), &None));
3063 }
3064
3065 #[test]
3066 fn eq_as_set_order_independent() {
3067 let a = vec!["ns1".to_string(), "ns2".to_string()];
3068 let b = vec!["ns2".to_string(), "ns1".to_string()];
3069 assert!(eq_as_set(&a, &b));
3070 }
3071
3072 #[test]
3073 fn eq_as_set_trims_and_drops_empty() {
3074 let a = vec!["ns1".to_string(), " ".to_string(), " ns2 ".to_string()];
3075 let b = vec!["ns2".to_string(), "ns1".to_string()];
3076 assert!(eq_as_set(&a, &b));
3077 }
3078
3079 #[test]
3080 fn eq_as_set_different_contents() {
3081 let a = vec!["1.2.3.4".to_string()];
3082 let b = vec!["1.2.3.5".to_string()];
3083 assert!(!eq_as_set(&a, &b));
3084 }
3085
3086 #[test]
3087 fn eq_as_set_both_empty_matches() {
3088 let a: Vec<String> = vec![];
3089 let b: Vec<String> = vec![];
3090 assert!(eq_as_set(&a, &b));
3091 }
3092
3093 #[test]
3094 fn wrap_cell_short_returns_single_line() {
3095 assert_eq!(wrap_cell("hello", 10), vec!["hello".to_string()]);
3096 }
3097
3098 #[test]
3099 fn wrap_cell_wraps_at_word_boundary() {
3100 let out = wrap_cell("the quick brown fox", 10);
3101 assert_eq!(out, vec!["the quick".to_string(), "brown fox".to_string()]);
3102 }
3103
3104 #[test]
3105 fn wrap_cell_hard_breaks_when_no_whitespace() {
3106 let out = wrap_cell("a.very.long.nameserver.example", 10);
3108 assert_eq!(
3109 out,
3110 vec![
3111 "a.very.lon".to_string(),
3112 "g.nameserv".to_string(),
3113 "er.example".to_string(),
3114 ]
3115 );
3116 }
3117
3118 #[test]
3119 fn wrap_cell_exact_width_no_wrap() {
3120 assert_eq!(wrap_cell("1234567890", 10), vec!["1234567890".to_string()]);
3121 }
3122
3123 #[test]
3124 fn wrap_cell_empty_input_returns_one_empty_line() {
3125 assert_eq!(wrap_cell("", 10), vec!["".to_string()]);
3126 }
3127
3128 #[test]
3129 fn wrap_cell_zero_width_treated_as_one() {
3130 let out = wrap_cell("abc", 0);
3133 assert_eq!(out, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
3134 }
3135
3136 fn make_sample_diff() -> DomainDiff {
3137 DomainDiff {
3138 domain_a: "example.com".to_string(),
3139 domain_b: "google.com".to_string(),
3140 registration: RegistrationDiff {
3141 registrar: (Some("IANA".to_string()), Some("MarkMonitor".to_string())),
3142 organization: (None, Some("Google LLC".to_string())),
3143 created: (
3144 Some("1995-08-14".to_string()),
3145 Some("1997-09-15".to_string()),
3146 ),
3147 expires: (
3148 Some("2026-08-13".to_string()),
3149 Some("2028-09-14".to_string()),
3150 ),
3151 },
3152 dns: DnsDiff {
3153 a_records: (
3154 vec!["93.184.216.34".to_string()],
3155 vec!["142.250.185.46".to_string()],
3156 ),
3157 nameservers: (
3158 vec!["ns1.example".to_string(), "ns2.example".to_string()],
3159 vec!["ns2.example".to_string(), "ns1.example".to_string()],
3160 ),
3161 resolves: (true, true),
3162 },
3163 ssl: SslDiff {
3164 issuer: (
3165 Some("DigiCert".to_string()),
3166 Some("Google Trust".to_string()),
3167 ),
3168 valid_until: (
3169 Some("2025-03-01".to_string()),
3170 Some("2025-02-15".to_string()),
3171 ),
3172 days_remaining: (Some(89), Some(75)),
3173 is_valid: (Some(true), Some(true)),
3174 },
3175 }
3176 }
3177
3178 #[test]
3179 fn build_diff_sections_produces_three_sections() {
3180 let diff = make_sample_diff();
3181 let sections = build_diff_sections(&diff);
3182 assert_eq!(sections.len(), 3);
3183 assert_eq!(sections[0].title, "Registration");
3184 assert_eq!(sections[1].title, "DNS");
3185 assert_eq!(sections[2].title, "SSL");
3186 }
3187
3188 #[test]
3189 fn build_diff_sections_marks_nameservers_as_match_when_sets_equal() {
3190 let diff = make_sample_diff();
3191 let sections = build_diff_sections(&diff);
3192 let dns = §ions[1];
3193 let ns_row = dns.rows.iter().find(|r| r.label == "Nameservers").unwrap();
3194 assert!(ns_row.matches, "reversed-order nameservers should match");
3195 }
3196
3197 #[test]
3198 fn build_diff_sections_marks_registrar_differ() {
3199 let diff = make_sample_diff();
3200 let sections = build_diff_sections(&diff);
3201 let reg = §ions[0];
3202 let row = reg.rows.iter().find(|r| r.label == "Registrar").unwrap();
3203 assert!(!row.matches);
3204 }
3205
3206 #[test]
3207 fn build_diff_sections_marks_resolves_match_when_both_true() {
3208 let diff = make_sample_diff();
3209 let sections = build_diff_sections(&diff);
3210 let dns = §ions[1];
3211 let row = dns.rows.iter().find(|r| r.label == "Resolves").unwrap();
3212 assert!(row.matches);
3213 assert_eq!(row.a_values, vec!["yes".to_string()]);
3214 assert_eq!(row.b_values, vec!["yes".to_string()]);
3215 }
3216
3217 #[test]
3218 fn build_diff_sections_renders_none_as_em_dash() {
3219 let diff = make_sample_diff();
3220 let sections = build_diff_sections(&diff);
3221 let reg = §ions[0];
3222 let row = reg.rows.iter().find(|r| r.label == "Organization").unwrap();
3223 assert_eq!(row.a_values, vec!["—".to_string()]);
3224 }
3225
3226 #[test]
3227 fn build_diff_sections_a_records_one_item_per_row() {
3228 let mut diff = make_sample_diff();
3229 diff.dns.a_records = (
3230 vec!["1.1.1.1".to_string(), "2.2.2.2".to_string()],
3231 vec!["3.3.3.3".to_string()],
3232 );
3233 let sections = build_diff_sections(&diff);
3234 let dns = §ions[1];
3235 let row = dns.rows.iter().find(|r| r.label == "A Records").unwrap();
3236 assert_eq!(row.a_values.len(), 2);
3237 assert_eq!(row.b_values.len(), 1);
3238 }
3239
3240 #[test]
3241 fn build_diff_sections_preserves_field_order() {
3242 let diff = make_sample_diff();
3243 let sections = build_diff_sections(&diff);
3244 let labels: Vec<&str> = sections[0].rows.iter().map(|r| r.label).collect();
3245 assert_eq!(
3246 labels,
3247 vec!["Registrar", "Organization", "Created", "Expires"]
3248 );
3249 let dns_labels: Vec<&str> = sections[1].rows.iter().map(|r| r.label).collect();
3250 assert_eq!(dns_labels, vec!["Resolves", "A Records", "Nameservers"]);
3251 let ssl_labels: Vec<&str> = sections[2].rows.iter().map(|r| r.label).collect();
3252 assert_eq!(
3253 ssl_labels,
3254 vec!["Issuer", "Valid Until", "Days Remaining", "Valid"]
3255 );
3256 }
3257
3258 #[test]
3259 fn compute_column_width_uses_widest_value_across_sections() {
3260 let sections = vec![DiffSection {
3261 title: "Registration",
3262 rows: vec![
3263 DiffRow {
3264 label: "Registrar",
3265 a_values: vec!["IANA".to_string()],
3266 b_values: vec!["MarkMonitor".to_string()],
3267 matches: false,
3268 },
3269 DiffRow {
3270 label: "Organization",
3271 a_values: vec!["—".to_string()],
3272 b_values: vec!["Google LLC".to_string()],
3273 matches: false,
3274 },
3275 ],
3276 }];
3277 assert_eq!(compute_column_width(§ions, "a.com", "b.com"), 11);
3279 }
3280
3281 #[test]
3282 fn compute_column_width_respects_domain_width() {
3283 let sections = vec![DiffSection {
3284 title: "Registration",
3285 rows: vec![DiffRow {
3286 label: "Registrar",
3287 a_values: vec!["x".to_string()],
3288 b_values: vec!["y".to_string()],
3289 matches: false,
3290 }],
3291 }];
3292 let w = compute_column_width(§ions, "very-long-domain.example", "b.com");
3294 assert_eq!(w, "very-long-domain.example".chars().count());
3295 }
3296
3297 #[test]
3298 fn compute_column_width_caps_at_40() {
3299 let long_value = "x".repeat(100);
3300 let sections = vec![DiffSection {
3301 title: "Registration",
3302 rows: vec![DiffRow {
3303 label: "Registrar",
3304 a_values: vec![long_value],
3305 b_values: vec!["y".to_string()],
3306 matches: false,
3307 }],
3308 }];
3309 assert_eq!(compute_column_width(§ions, "a.com", "b.com"), 40);
3310 }
3311
3312 #[test]
3313 fn compute_column_width_minimum_sensible_default() {
3314 let sections = vec![DiffSection {
3316 title: "Registration",
3317 rows: vec![DiffRow {
3318 label: "X",
3319 a_values: vec!["a".to_string()],
3320 b_values: vec!["b".to_string()],
3321 matches: true,
3322 }],
3323 }];
3324 let w = compute_column_width(§ions, "a", "b");
3325 assert!(w >= 1);
3326 }
3327
3328 fn diff_formatter() -> HumanFormatter {
3329 HumanFormatter::new().without_colors()
3330 }
3331
3332 #[test]
3333 fn format_diff_shows_column_headers_with_domain_names() {
3334 let f = diff_formatter();
3335 let out = f.format_diff(&make_sample_diff());
3336 assert!(
3337 out.contains("example.com"),
3338 "missing domain_a in output:\n{}",
3339 out
3340 );
3341 assert!(
3342 out.contains("google.com"),
3343 "missing domain_b in output:\n{}",
3344 out
3345 );
3346 assert!(out.contains("──"), "missing header underline:\n{}", out);
3348 }
3349
3350 #[test]
3351 fn format_diff_marks_differing_rows_with_neq() {
3352 let f = diff_formatter();
3353 let out = f.format_diff(&make_sample_diff());
3354 let registrar_line = out
3356 .lines()
3357 .find(|l| l.contains("Registrar"))
3358 .expect("registrar line missing");
3359 assert!(
3360 registrar_line.contains("≠"),
3361 "registrar row should be marked differ: {}",
3362 registrar_line
3363 );
3364 }
3365
3366 #[test]
3367 fn format_diff_marks_matching_rows_with_eq() {
3368 let f = diff_formatter();
3369 let out = f.format_diff(&make_sample_diff());
3370 let resolves_line = out
3372 .lines()
3373 .find(|l| l.contains("Resolves"))
3374 .expect("resolves line missing");
3375 assert!(
3376 resolves_line.contains('='),
3377 "resolves row should be marked match: {}",
3378 resolves_line
3379 );
3380 assert!(!resolves_line.contains('≠'));
3381 }
3382
3383 #[test]
3384 fn format_diff_nameservers_reversed_order_is_match() {
3385 let f = diff_formatter();
3387 let out = f.format_diff(&make_sample_diff());
3388 let ns_line = out
3389 .lines()
3390 .find(|l| l.contains("Nameservers"))
3391 .expect("nameservers line missing");
3392 assert!(
3393 ns_line.contains('=') && !ns_line.contains('≠'),
3394 "nameservers row should match (set equality): {}",
3395 ns_line
3396 );
3397 }
3398
3399 #[test]
3400 fn format_diff_organization_none_renders_em_dash() {
3401 let f = diff_formatter();
3402 let out = f.format_diff(&make_sample_diff());
3403 let org_line = out
3404 .lines()
3405 .find(|l| l.contains("Organization"))
3406 .expect("organization line missing");
3407 assert!(org_line.contains("—"), "expected em dash: {}", org_line);
3408 }
3409
3410 #[test]
3411 fn format_diff_multi_value_a_records_one_per_line() {
3412 let mut diff = make_sample_diff();
3413 diff.dns.a_records = (
3414 vec!["1.1.1.1".to_string(), "2.2.2.2".to_string()],
3415 vec!["3.3.3.3".to_string(), "4.4.4.4".to_string()],
3416 );
3417 let f = diff_formatter();
3418 let out = f.format_diff(&diff);
3419 assert!(out.contains("1.1.1.1"), "missing 1.1.1.1:\n{}", out);
3420 assert!(out.contains("2.2.2.2"), "missing 2.2.2.2:\n{}", out);
3421 assert!(out.contains("3.3.3.3"), "missing 3.3.3.3:\n{}", out);
3422 assert!(out.contains("4.4.4.4"), "missing 4.4.4.4:\n{}", out);
3423 let a_records_label_count = out.matches("A Records").count();
3425 assert_eq!(
3426 a_records_label_count, 1,
3427 "A Records label should appear exactly once:\n{}",
3428 out
3429 );
3430 }
3431
3432 #[test]
3433 fn format_diff_wraps_long_scalar_values() {
3434 let mut diff = make_sample_diff();
3435 let long = "a".repeat(60);
3436 diff.ssl.issuer = (Some(long.clone()), Some("short".to_string()));
3437 let f = diff_formatter();
3438 let out = f.format_diff(&diff);
3439 for line in out.lines() {
3441 assert!(
3442 !line.contains(&long),
3443 "unwrapped 60-char value on one line: {}",
3444 line
3445 );
3446 }
3447 let chars_present = out.matches('a').count();
3449 assert!(
3450 chars_present >= 60,
3451 "wrapped value should preserve all chars, got {}",
3452 chars_present
3453 );
3454 }
3455
3456 #[test]
3457 fn format_diff_plain_mode_contains_marker_glyphs() {
3458 let out = diff_formatter().format_diff(&make_sample_diff());
3459 assert!(out.contains('='), "plain mode missing =");
3460 assert!(out.contains('≠'), "plain mode missing ≠");
3461 assert!(out.contains('─'), "plain mode missing header rule ─");
3462 }
3463
3464 #[test]
3465 fn format_diff_all_matching_has_no_neq() {
3466 let diff = DomainDiff {
3467 domain_a: "a.com".to_string(),
3468 domain_b: "a.com".to_string(),
3469 registration: RegistrationDiff {
3470 registrar: (Some("X".to_string()), Some("X".to_string())),
3471 organization: (Some("Org".to_string()), Some("Org".to_string())),
3472 created: (Some("2020".to_string()), Some("2020".to_string())),
3473 expires: (Some("2030".to_string()), Some("2030".to_string())),
3474 },
3475 dns: DnsDiff {
3476 a_records: (vec!["1.1.1.1".to_string()], vec!["1.1.1.1".to_string()]),
3477 nameservers: (vec!["ns".to_string()], vec!["ns".to_string()]),
3478 resolves: (true, true),
3479 },
3480 ssl: SslDiff {
3481 issuer: (Some("I".to_string()), Some("I".to_string())),
3482 valid_until: (Some("2030".to_string()), Some("2030".to_string())),
3483 days_remaining: (Some(10), Some(10)),
3484 is_valid: (Some(true), Some(true)),
3485 },
3486 };
3487 let out = diff_formatter().format_diff(&diff);
3488 assert!(
3489 !out.contains('≠'),
3490 "all-match diff should have no ≠:\n{}",
3491 out
3492 );
3493 }
3494
3495 #[test]
3496 fn format_diff_all_differing_has_no_eq() {
3497 let diff = DomainDiff {
3498 domain_a: "a.com".to_string(),
3499 domain_b: "b.com".to_string(),
3500 registration: RegistrationDiff {
3501 registrar: (Some("X".to_string()), Some("Y".to_string())),
3502 organization: (Some("OrgX".to_string()), Some("OrgY".to_string())),
3503 created: (Some("2020".to_string()), Some("2021".to_string())),
3504 expires: (Some("2030".to_string()), Some("2031".to_string())),
3505 },
3506 dns: DnsDiff {
3507 a_records: (vec!["1.1.1.1".to_string()], vec!["2.2.2.2".to_string()]),
3508 nameservers: (vec!["nsa".to_string()], vec!["nsb".to_string()]),
3509 resolves: (true, false),
3510 },
3511 ssl: SslDiff {
3512 issuer: (Some("IA".to_string()), Some("IB".to_string())),
3513 valid_until: (Some("2030".to_string()), Some("2031".to_string())),
3514 days_remaining: (Some(10), Some(20)),
3515 is_valid: (Some(true), Some(false)),
3516 },
3517 };
3518 let out = diff_formatter().format_diff(&diff);
3519 for line in out.lines() {
3523 if line.starts_with(" ") && line.len() > 10 {
3524 assert!(
3525 !line.contains('='),
3526 "all-differing diff should have no = on field rows: {}",
3527 line
3528 );
3529 }
3530 }
3531 }
3532
3533 #[test]
3534 fn format_diff_uneven_list_lengths_pad_shorter_side() {
3535 let mut diff = make_sample_diff();
3536 diff.dns.nameservers = (
3537 vec!["ns1".to_string(), "ns2".to_string(), "ns3".to_string()],
3538 vec!["only".to_string()],
3539 );
3540 let out = diff_formatter().format_diff(&diff);
3541 assert!(out.contains("ns1"), "ns1 missing:\n{}", out);
3543 assert!(out.contains("ns2"), "ns2 missing:\n{}", out);
3544 assert!(out.contains("ns3"), "ns3 missing:\n{}", out);
3545 assert!(out.contains("only"), "right-side 'only' missing:\n{}", out);
3547 assert_eq!(
3550 out.matches("only").count(),
3551 1,
3552 "right-side value must appear exactly once:\n{}",
3553 out
3554 );
3555 }
3556
3557 fn availability_lookup(available: bool, confidence: &str) -> LookupResult {
3558 LookupResult::Available {
3559 data: Box::new(crate::availability::AvailabilityResult {
3560 domain: "myroyalcanin.lv".to_string(),
3561 available,
3562 confidence: confidence.to_string(),
3563 method: "whois".to_string(),
3564 details: None,
3565 }),
3566 rdap_error: "bootstrap failed".to_string(),
3567 whois_error: String::new(),
3568 whois_data: None,
3569 }
3570 }
3571
3572 #[test]
3573 fn format_lookup_registered_high_confidence_does_not_say_available() {
3574 let f = formatter();
3578 let out = f.format_lookup(&availability_lookup(false, "high"));
3579 assert!(
3580 !out.contains("AVAILABLE"),
3581 "must not claim available:\n{}",
3582 out
3583 );
3584 assert!(
3585 out.contains("REGISTERED"),
3586 "must render REGISTERED:\n{}",
3587 out
3588 );
3589 assert!(
3590 out.contains("(registered)"),
3591 "header suffix must say registered:\n{}",
3592 out
3593 );
3594 }
3595
3596 #[test]
3597 fn format_lookup_available_high_confidence_still_says_available() {
3598 let f = formatter();
3599 let out = f.format_lookup(&availability_lookup(true, "high"));
3600 assert!(
3601 out.contains("AVAILABLE"),
3602 "high-confidence available:\n{}",
3603 out
3604 );
3605 assert!(out.contains("(available)"), "header suffix:\n{}", out);
3606 }
3607
3608 #[test]
3609 fn format_lookup_likely_registered_medium_confidence() {
3610 let f = formatter();
3611 let out = f.format_lookup(&availability_lookup(false, "medium"));
3612 assert!(out.contains("LIKELY REGISTERED"), "medium reg:\n{}", out);
3613 assert!(
3614 !out.contains("MAY BE AVAILABLE"),
3615 "must not say MAY BE AVAILABLE:\n{}",
3616 out
3617 );
3618 }
3619
3620 #[test]
3621 fn domain_info_verdict_registered_for_high_confidence_unavailable() {
3622 let lookup = availability_lookup(false, "high");
3624 let info = crate::domain_info::DomainInfo::from_lookup_result(&lookup);
3625 assert_eq!(info.availability_verdict.as_deref(), Some("registered"));
3626 }
3627
3628 #[test]
3629 fn format_diff_em_dash_is_dim_not_row_color() {
3630 colored::control::set_override(true);
3633 let f = HumanFormatter::new();
3634 let out = f.format_diff(&make_sample_diff());
3635 colored::control::unset_override();
3636
3637 let org_line = out
3647 .lines()
3648 .find(|l| l.contains("Organization"))
3649 .expect("organization line missing");
3650 assert!(
3651 !org_line.contains("\x1b[91m—"),
3652 "em-dash should not be red on a differ row: {:?}",
3653 org_line
3654 );
3655 assert!(
3656 org_line.contains("\x1b[90m"),
3657 "em-dash should be dim (bright-black ANSI): {:?}",
3658 org_line
3659 );
3660 }
3661}