seer_core/output/
human.rs

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(&registrar)
189            ));
190        }
191
192        if let Some(registrant) = response.get_registrant() {
193            output.push(format!(
194                "  {}: {}",
195                self.label("Registrant"),
196                self.value(&registrant)
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        // IP-specific fields
248        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        // ASN-specific fields
273        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        // Summary
322        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        // Consensus values
343        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        // Inconsistencies
351        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        // Group results by region
359        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        // Sort regions for consistent output
368        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 &regions {
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(&registrar)
438                    ));
439                }
440
441                if let Some(registrant) = data.get_registrant() {
442                    output.push(format!(
443                        "  {}: {}",
444                        self.label("Registrant"),
445                        self.value(&registrant)
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        // HTTP Status
590        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        // Site Title
610        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        // SSL Certificate
619        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        // Domain Expiration
671        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}