1use colored::Colorize;
2
3use super::OutputFormatter;
4use crate::colors::CatppuccinExt;
5use crate::dns::{DnsRecord, PropagationResult};
6use crate::lookup::LookupResult;
7use crate::rdap::RdapResponse;
8use crate::status::StatusResponse;
9use crate::whois::WhoisResponse;
10
11pub struct HumanFormatter {
12 use_colors: bool,
13}
14
15impl Default for HumanFormatter {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl HumanFormatter {
22 pub fn new() -> Self {
23 Self { use_colors: true }
24 }
25
26 pub fn without_colors(mut self) -> Self {
27 self.use_colors = false;
28 self
29 }
30
31 fn label(&self, text: &str) -> String {
32 if self.use_colors {
33 text.sky().bold().to_string()
34 } else {
35 text.to_string()
36 }
37 }
38
39 fn value(&self, text: &str) -> String {
40 if self.use_colors {
41 text.ctp_white().to_string()
42 } else {
43 text.to_string()
44 }
45 }
46
47 fn success(&self, text: &str) -> String {
48 if self.use_colors {
49 text.ctp_green().bold().to_string()
50 } else {
51 text.to_string()
52 }
53 }
54
55 fn warning(&self, text: &str) -> String {
56 if self.use_colors {
57 text.ctp_yellow().bold().to_string()
58 } else {
59 text.to_string()
60 }
61 }
62
63 fn error(&self, text: &str) -> String {
64 if self.use_colors {
65 text.ctp_red().bold().to_string()
66 } else {
67 text.to_string()
68 }
69 }
70
71 fn header(&self, text: &str) -> String {
72 if self.use_colors {
73 format!("\n{}\n{}", text.lavender().bold(), "─".repeat(text.len()).subtext0())
74 } else {
75 format!("\n{}\n{}", text, "-".repeat(text.len()))
76 }
77 }
78}
79
80impl OutputFormatter for HumanFormatter {
81 fn format_whois(&self, response: &WhoisResponse) -> String {
82 let mut output = Vec::new();
83
84 output.push(self.header(&format!("WHOIS: {}", response.domain)));
85
86 if response.is_available() {
87 output.push(format!(" {} Domain is available", self.success("✓")));
88 return output.join("\n");
89 }
90
91 if let Some(ref registrar) = response.registrar {
92 output.push(format!(
93 " {}: {}",
94 self.label("Registrar"),
95 self.value(registrar)
96 ));
97 }
98
99 if let Some(ref registrant) = response.registrant {
100 output.push(format!(
101 " {}: {}",
102 self.label("Registrant"),
103 self.value(registrant)
104 ));
105 }
106
107 if let Some(created) = response.creation_date {
108 output.push(format!(
109 " {}: {}",
110 self.label("Created"),
111 self.value(&created.format("%Y-%m-%d").to_string())
112 ));
113 }
114
115 if let Some(expires) = response.expiration_date {
116 let days_until = (expires - chrono::Utc::now()).num_days();
117 let expiry_str = expires.format("%Y-%m-%d").to_string();
118 let status = if days_until < 30 {
119 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
120 } else if days_until < 90 {
121 self.warning(&format!("{} ({} days)", expiry_str, days_until))
122 } else {
123 self.value(&format!("{} ({} days)", expiry_str, days_until))
124 };
125 output.push(format!(" {}: {}", self.label("Expires"), status));
126 }
127
128 if let Some(updated) = response.updated_date {
129 output.push(format!(
130 " {}: {}",
131 self.label("Updated"),
132 self.value(&updated.format("%Y-%m-%d").to_string())
133 ));
134 }
135
136 if !response.nameservers.is_empty() {
137 output.push(format!(" {}:", self.label("Nameservers")));
138 for ns in &response.nameservers {
139 output.push(format!(" - {}", self.value(ns)));
140 }
141 }
142
143 if !response.status.is_empty() {
144 output.push(format!(" {}:", self.label("Status")));
145 for status in &response.status {
146 output.push(format!(" - {}", self.value(status)));
147 }
148 }
149
150 if let Some(ref dnssec) = response.dnssec {
151 output.push(format!(
152 " {}: {}",
153 self.label("DNSSEC"),
154 self.value(dnssec)
155 ));
156 }
157
158 output.push(format!(
159 " {}: {}",
160 self.label("WHOIS Server"),
161 self.value(&response.whois_server)
162 ));
163
164 output.join("\n")
165 }
166
167 fn format_rdap(&self, response: &RdapResponse) -> String {
168 let mut output = Vec::new();
169
170 let name = response
171 .domain_name()
172 .or(response.name.as_deref())
173 .unwrap_or("Unknown");
174 output.push(self.header(&format!("RDAP: {}", name)));
175
176 if let Some(handle) = &response.handle {
177 output.push(format!(
178 " {}: {}",
179 self.label("Handle"),
180 self.value(handle)
181 ));
182 }
183
184 if let Some(registrar) = response.get_registrar() {
185 output.push(format!(
186 " {}: {}",
187 self.label("Registrar"),
188 self.value(®istrar)
189 ));
190 }
191
192 if let Some(registrant) = response.get_registrant() {
193 output.push(format!(
194 " {}: {}",
195 self.label("Registrant"),
196 self.value(®istrant)
197 ));
198 }
199
200 if let Some(created) = response.creation_date() {
201 output.push(format!(
202 " {}: {}",
203 self.label("Created"),
204 self.value(&created.format("%Y-%m-%d").to_string())
205 ));
206 }
207
208 if let Some(expires) = response.expiration_date() {
209 output.push(format!(
210 " {}: {}",
211 self.label("Expires"),
212 self.value(&expires.format("%Y-%m-%d").to_string())
213 ));
214 }
215
216 if let Some(updated) = response.last_updated() {
217 output.push(format!(
218 " {}: {}",
219 self.label("Updated"),
220 self.value(&updated.format("%Y-%m-%d").to_string())
221 ));
222 }
223
224 if !response.status.is_empty() {
225 output.push(format!(" {}:", self.label("Status")));
226 for status in &response.status {
227 output.push(format!(" - {}", self.value(status)));
228 }
229 }
230
231 let nameservers = response.nameserver_names();
232 if !nameservers.is_empty() {
233 output.push(format!(" {}:", self.label("Nameservers")));
234 for ns in &nameservers {
235 output.push(format!(" - {}", self.value(ns)));
236 }
237 }
238
239 if response.is_dnssec_signed() {
240 output.push(format!(
241 " {}: {}",
242 self.label("DNSSEC"),
243 self.success("signed")
244 ));
245 }
246
247 if let Some(ref start) = response.start_address {
249 output.push(format!(
250 " {}: {}",
251 self.label("Start Address"),
252 self.value(start)
253 ));
254 }
255
256 if let Some(ref end) = response.end_address {
257 output.push(format!(
258 " {}: {}",
259 self.label("End Address"),
260 self.value(end)
261 ));
262 }
263
264 if let Some(ref country) = response.country {
265 output.push(format!(
266 " {}: {}",
267 self.label("Country"),
268 self.value(country)
269 ));
270 }
271
272 if let Some(start) = response.start_autnum {
274 output.push(format!(
275 " {}: {}",
276 self.label("AS Number"),
277 self.value(&format!(
278 "AS{} - AS{}",
279 start,
280 response.end_autnum.unwrap_or(start)
281 ))
282 ));
283 }
284
285 output.join("\n")
286 }
287
288 fn format_dns(&self, records: &[DnsRecord]) -> String {
289 let mut output = Vec::new();
290
291 if records.is_empty() {
292 output.push(self.warning("No records found"));
293 return output.join("\n");
294 }
295
296 let domain = &records[0].name;
297 let record_type = &records[0].record_type;
298 output.push(self.header(&format!("DNS {} Records: {}", record_type, domain)));
299
300 for record in records {
301 output.push(format!(
302 " {} {} {} {}",
303 self.value(&record.name),
304 self.label(&format!("{}", record.ttl)),
305 self.label(&format!("{}", record.record_type)),
306 self.success(&record.data.to_string())
307 ));
308 }
309
310 output.join("\n")
311 }
312
313 fn format_propagation(&self, result: &PropagationResult) -> String {
314 let mut output = Vec::new();
315
316 output.push(self.header(&format!(
317 "Propagation Check: {} {}",
318 result.domain, result.record_type
319 )));
320
321 let percentage = result.propagation_percentage;
323 let percentage_str = format!("{:.1}%", percentage);
324 let status = if percentage >= 100.0 {
325 self.success(&format!("✓ Fully propagated ({})", percentage_str))
326 } else if percentage >= 80.0 {
327 self.warning(&format!("◐ Mostly propagated ({})", percentage_str))
328 } else if percentage >= 50.0 {
329 self.warning(&format!("◑ Partially propagated ({})", percentage_str))
330 } else {
331 self.error(&format!("✗ Not propagated ({})", percentage_str))
332 };
333 output.push(format!(" {}", status));
334
335 output.push(format!(
336 " {}: {}/{}",
337 self.label("Servers responding"),
338 result.servers_responding,
339 result.servers_checked
340 ));
341
342 if !result.consensus_values.is_empty() {
344 output.push(format!(" {}:", self.label("Consensus values")));
345 for value in &result.consensus_values {
346 output.push(format!(" - {}", self.success(value)));
347 }
348 }
349
350 if !result.inconsistencies.is_empty() {
352 output.push(format!(" {}:", self.label("Inconsistencies")));
353 for inconsistency in &result.inconsistencies {
354 output.push(format!(" - {}", self.warning(inconsistency)));
355 }
356 }
357
358 let mut by_region: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
360 for server_result in &result.results {
361 by_region
362 .entry(server_result.server.location.as_str())
363 .or_default()
364 .push(server_result);
365 }
366
367 let mut regions: Vec<_> = by_region.keys().cloned().collect();
369 regions.sort();
370
371 output.push(format!("\n {}:", self.label("Results by Region")));
372 for region in ®ions {
373 output.push(format!("\n {}:", self.label(region)));
374 if let Some(server_results) = by_region.get(region) {
375 for server_result in server_results {
376 let status_icon = if server_result.success { "✓" } else { "✗" };
377 let status_colored = if server_result.success {
378 self.success(status_icon)
379 } else {
380 self.error(status_icon)
381 };
382
383 let values = if server_result.success {
384 if server_result.records.is_empty() {
385 "NXDOMAIN".to_string()
386 } else {
387 server_result
388 .records
389 .iter()
390 .map(|r| r.format_short())
391 .collect::<Vec<_>>()
392 .join(", ")
393 }
394 } else {
395 server_result
396 .error
397 .as_deref()
398 .unwrap_or("Error")
399 .to_string()
400 };
401
402 output.push(format!(
403 " {} {} ({}) - {} [{}ms]",
404 status_colored,
405 self.value(&server_result.server.name),
406 server_result.server.ip,
407 values,
408 server_result.response_time_ms
409 ));
410 }
411 }
412 }
413
414 output.join("\n")
415 }
416
417 fn format_lookup(&self, result: &LookupResult) -> String {
418 let mut output = Vec::new();
419
420 let domain = result.domain_name().unwrap_or_else(|| "Unknown".to_string());
421 let source = if result.is_rdap() { "RDAP" } else { "WHOIS" };
422
423 output.push(self.header(&format!("Lookup: {} (via {})", domain, source)));
424
425 match result {
426 LookupResult::Rdap { data, whois_fallback } => {
427 output.push(format!(
428 " {}: {}",
429 self.label("Source"),
430 self.success("RDAP (modern protocol)")
431 ));
432
433 if let Some(registrar) = data.get_registrar() {
434 output.push(format!(
435 " {}: {}",
436 self.label("Registrar"),
437 self.value(®istrar)
438 ));
439 }
440
441 if let Some(registrant) = data.get_registrant() {
442 output.push(format!(
443 " {}: {}",
444 self.label("Registrant"),
445 self.value(®istrant)
446 ));
447 }
448
449 if let Some(created) = data.creation_date() {
450 output.push(format!(
451 " {}: {}",
452 self.label("Created"),
453 self.value(&created.format("%Y-%m-%d").to_string())
454 ));
455 }
456
457 if let Some(expires) = data.expiration_date() {
458 let days_until = (expires - chrono::Utc::now()).num_days();
459 let expiry_str = expires.format("%Y-%m-%d").to_string();
460 let status = if days_until < 30 {
461 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
462 } else if days_until < 90 {
463 self.warning(&format!("{} ({} days)", expiry_str, days_until))
464 } else {
465 self.value(&format!("{} ({} days)", expiry_str, days_until))
466 };
467 output.push(format!(" {}: {}", self.label("Expires"), status));
468 }
469
470 if !data.status.is_empty() {
471 output.push(format!(" {}:", self.label("Status")));
472 for status in &data.status {
473 output.push(format!(" - {}", self.value(status)));
474 }
475 }
476
477 let nameservers = data.nameserver_names();
478 if !nameservers.is_empty() {
479 output.push(format!(" {}:", self.label("Nameservers")));
480 for ns in &nameservers {
481 output.push(format!(" - {}", self.value(ns)));
482 }
483 }
484
485 if data.is_dnssec_signed() {
486 output.push(format!(
487 " {}: {}",
488 self.label("DNSSEC"),
489 self.success("signed")
490 ));
491 }
492
493 if let Some(whois) = whois_fallback {
494 output.push(format!("\n {}", self.label("Additional WHOIS data:")));
495 if let Some(ref raw) = whois.dnssec {
496 output.push(format!(" DNSSEC: {}", self.value(raw)));
497 }
498 }
499 }
500 LookupResult::Whois { data, rdap_error } => {
501 let source_note = if rdap_error.is_some() {
502 "WHOIS (RDAP unavailable)"
503 } else {
504 "WHOIS"
505 };
506 output.push(format!(
507 " {}: {}",
508 self.label("Source"),
509 self.warning(source_note)
510 ));
511
512 if let Some(ref error) = rdap_error {
513 output.push(format!(
514 " {}: {}",
515 self.label("RDAP Error"),
516 self.error(error)
517 ));
518 }
519
520 if let Some(ref registrar) = data.registrar {
521 output.push(format!(
522 " {}: {}",
523 self.label("Registrar"),
524 self.value(registrar)
525 ));
526 }
527
528 if let Some(ref registrant) = data.registrant {
529 output.push(format!(
530 " {}: {}",
531 self.label("Registrant"),
532 self.value(registrant)
533 ));
534 }
535
536 if let Some(created) = data.creation_date {
537 output.push(format!(
538 " {}: {}",
539 self.label("Created"),
540 self.value(&created.format("%Y-%m-%d").to_string())
541 ));
542 }
543
544 if let Some(expires) = data.expiration_date {
545 let days_until = (expires - chrono::Utc::now()).num_days();
546 let expiry_str = expires.format("%Y-%m-%d").to_string();
547 let status = if days_until < 30 {
548 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
549 } else if days_until < 90 {
550 self.warning(&format!("{} ({} days)", expiry_str, days_until))
551 } else {
552 self.value(&format!("{} ({} days)", expiry_str, days_until))
553 };
554 output.push(format!(" {}: {}", self.label("Expires"), status));
555 }
556
557 if !data.status.is_empty() {
558 output.push(format!(" {}:", self.label("Status")));
559 for status in &data.status {
560 output.push(format!(" - {}", self.value(status)));
561 }
562 }
563
564 if !data.nameservers.is_empty() {
565 output.push(format!(" {}:", self.label("Nameservers")));
566 for ns in &data.nameservers {
567 output.push(format!(" - {}", self.value(ns)));
568 }
569 }
570
571 if let Some(ref dnssec) = data.dnssec {
572 output.push(format!(
573 " {}: {}",
574 self.label("DNSSEC"),
575 self.value(dnssec)
576 ));
577 }
578 }
579 }
580
581 output.join("\n")
582 }
583
584 fn format_status(&self, response: &StatusResponse) -> String {
585 let mut output = Vec::new();
586
587 output.push(self.header(&format!("Status: {}", response.domain)));
588
589 if let Some(status) = response.http_status {
591 let status_text = response
592 .http_status_text
593 .as_deref()
594 .unwrap_or("Unknown");
595 let status_display = if (200..300).contains(&status) {
596 self.success(&format!("{} ({})", status, status_text))
597 } else if (300..400).contains(&status) {
598 self.warning(&format!("{} ({})", status, status_text))
599 } else {
600 self.error(&format!("{} ({})", status, status_text))
601 };
602 output.push(format!(
603 " {}: {}",
604 self.label("HTTP Status"),
605 status_display
606 ));
607 }
608
609 if let Some(ref title) = response.title {
611 output.push(format!(
612 " {}: {}",
613 self.label("Site Title"),
614 self.value(title)
615 ));
616 }
617
618 if let Some(ref cert) = response.certificate {
620 output.push(format!("\n {}:", self.label("SSL Certificate")));
621 output.push(format!(
622 " {}: {}",
623 self.label("Subject"),
624 self.value(&cert.subject)
625 ));
626 output.push(format!(
627 " {}: {}",
628 self.label("Issuer"),
629 self.value(&cert.issuer)
630 ));
631
632 let valid_status = if cert.is_valid {
633 self.success("Valid")
634 } else {
635 self.error("Invalid")
636 };
637 output.push(format!(
638 " {}: {}",
639 self.label("Status"),
640 valid_status
641 ));
642
643 output.push(format!(
644 " {}: {}",
645 self.label("Valid From"),
646 self.value(&cert.valid_from.format("%Y-%m-%d").to_string())
647 ));
648
649 let expiry_str = cert.valid_until.format("%Y-%m-%d").to_string();
650 let expiry_display = if cert.days_until_expiry < 30 {
651 self.error(&format!("{} ({} days!)", expiry_str, cert.days_until_expiry))
652 } else if cert.days_until_expiry < 90 {
653 self.warning(&format!("{} ({} days)", expiry_str, cert.days_until_expiry))
654 } else {
655 self.value(&format!("{} ({} days)", expiry_str, cert.days_until_expiry))
656 };
657 output.push(format!(
658 " {}: {}",
659 self.label("Expires"),
660 expiry_display
661 ));
662 } else {
663 output.push(format!(
664 "\n {}: {}",
665 self.label("SSL Certificate"),
666 self.warning("Not available (HTTPS may not be configured)")
667 ));
668 }
669
670 if let Some(ref expiry) = response.domain_expiration {
672 output.push(format!("\n {}:", self.label("Domain Registration")));
673
674 if let Some(ref registrar) = expiry.registrar {
675 output.push(format!(
676 " {}: {}",
677 self.label("Registrar"),
678 self.value(registrar)
679 ));
680 }
681
682 let expiry_str = expiry.expiration_date.format("%Y-%m-%d").to_string();
683 let expiry_display = if expiry.days_until_expiry < 30 {
684 self.error(&format!("{} ({} days!)", expiry_str, expiry.days_until_expiry))
685 } else if expiry.days_until_expiry < 90 {
686 self.warning(&format!("{} ({} days)", expiry_str, expiry.days_until_expiry))
687 } else {
688 self.value(&format!("{} ({} days)", expiry_str, expiry.days_until_expiry))
689 };
690 output.push(format!(
691 " {}: {}",
692 self.label("Expires"),
693 expiry_display
694 ));
695 }
696
697 output.join("\n")
698 }
699}