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