Skip to main content

seer_core/output/
markdown.rs

1use super::OutputFormatter;
2use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
3use crate::lookup::LookupResult;
4use crate::rdap::RdapResponse;
5use crate::status::StatusResponse;
6use crate::whois::WhoisResponse;
7
8/// Markdown output formatter that produces clean, readable Markdown.
9pub struct MarkdownFormatter;
10
11impl Default for MarkdownFormatter {
12    fn default() -> Self {
13        Self::new()
14    }
15}
16
17impl MarkdownFormatter {
18    pub fn new() -> Self {
19        Self
20    }
21
22    /// Formats a contact section for RDAP entities.
23    fn format_rdap_contact(
24        &self,
25        output: &mut Vec<String>,
26        label: &str,
27        contact: &crate::rdap::ContactInfo,
28    ) {
29        if !contact.has_info() {
30            return;
31        }
32        output.push(String::new());
33        output.push(format!("### {}", label));
34        output.push(String::new());
35        if let Some(ref name) = contact.name {
36            output.push(format!("- **Name**: {}", name));
37        }
38        if let Some(ref org) = contact.organization {
39            output.push(format!("- **Organization**: {}", org));
40        }
41        if let Some(ref email) = contact.email {
42            output.push(format!("- **Email**: `{}`", email));
43        }
44        if let Some(ref phone) = contact.phone {
45            output.push(format!("- **Phone**: {}", phone));
46        }
47        if let Some(ref address) = contact.address {
48            output.push(format!("- **Address**: {}", address));
49        }
50        if let Some(ref country) = contact.country {
51            output.push(format!("- **Country**: {}", country));
52        }
53    }
54
55    /// Formats WHOIS contact fields as a markdown subsection.
56    fn format_whois_contact(
57        &self,
58        output: &mut Vec<String>,
59        label: &str,
60        name: &Option<String>,
61        organization: &Option<String>,
62        email: &Option<String>,
63        phone: &Option<String>,
64    ) {
65        let has_info =
66            name.is_some() || organization.is_some() || email.is_some() || phone.is_some();
67        if !has_info {
68            return;
69        }
70        output.push(String::new());
71        output.push(format!("### {}", label));
72        output.push(String::new());
73        if let Some(ref v) = *name {
74            output.push(format!("- **Name**: {}", v));
75        }
76        if let Some(ref v) = *organization {
77            output.push(format!("- **Organization**: {}", v));
78        }
79        if let Some(ref v) = *email {
80            output.push(format!("- **Email**: `{}`", v));
81        }
82        if let Some(ref v) = *phone {
83            output.push(format!("- **Phone**: {}", v));
84        }
85    }
86}
87
88impl OutputFormatter for MarkdownFormatter {
89    fn format_whois(&self, response: &WhoisResponse) -> String {
90        let mut output = Vec::new();
91
92        output.push(format!("## WHOIS: {}", response.domain));
93        output.push(String::new());
94
95        if response.is_available() {
96            output.push("Domain is **available** for registration.".to_string());
97            return output.join("\n");
98        }
99
100        if let Some(ref registrar) = response.registrar {
101            output.push(format!("- **Registrar**: {}", registrar));
102        }
103        if let Some(ref registrant) = response.registrant {
104            output.push(format!("- **Registrant**: {}", registrant));
105        }
106        if let Some(ref organization) = response.organization {
107            output.push(format!("- **Organization**: {}", organization));
108        }
109
110        // Registrant contact details
111        let has_registrant_details = response.registrant_email.is_some()
112            || response.registrant_phone.is_some()
113            || response.registrant_address.is_some()
114            || response.registrant_country.is_some();
115
116        if has_registrant_details {
117            output.push(String::new());
118            output.push("### Registrant Contact".to_string());
119            output.push(String::new());
120            if let Some(ref email) = response.registrant_email {
121                output.push(format!("- **Email**: `{}`", email));
122            }
123            if let Some(ref phone) = response.registrant_phone {
124                output.push(format!("- **Phone**: {}", phone));
125            }
126            if let Some(ref address) = response.registrant_address {
127                output.push(format!("- **Address**: {}", address));
128            }
129            if let Some(ref country) = response.registrant_country {
130                output.push(format!("- **Country**: {}", country));
131            }
132        }
133
134        // Admin contact
135        self.format_whois_contact(
136            &mut output,
137            "Admin Contact",
138            &response.admin_name,
139            &response.admin_organization,
140            &response.admin_email,
141            &response.admin_phone,
142        );
143
144        // Tech contact
145        self.format_whois_contact(
146            &mut output,
147            "Tech Contact",
148            &response.tech_name,
149            &response.tech_organization,
150            &response.tech_email,
151            &response.tech_phone,
152        );
153
154        if let Some(created) = response.creation_date {
155            output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
156        }
157        if let Some(expires) = response.expiration_date {
158            let days_until = (expires - chrono::Utc::now()).num_days();
159            output.push(format!(
160                "- **Expires**: `{}` ({} days)",
161                expires.format("%Y-%m-%d"),
162                days_until
163            ));
164        }
165        if let Some(updated) = response.updated_date {
166            output.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
167        }
168
169        if !response.nameservers.is_empty() {
170            output.push(format!(
171                "- **Nameservers**: {}",
172                response
173                    .nameservers
174                    .iter()
175                    .map(|ns| format!("`{}`", ns))
176                    .collect::<Vec<_>>()
177                    .join(", ")
178            ));
179        }
180
181        if !response.status.is_empty() {
182            output.push(format!(
183                "- **Status**: {}",
184                response
185                    .status
186                    .iter()
187                    .map(|s| format!("`{}`", s))
188                    .collect::<Vec<_>>()
189                    .join(", ")
190            ));
191        }
192
193        if let Some(ref dnssec) = response.dnssec {
194            output.push(format!("- **DNSSEC**: {}", dnssec));
195        }
196
197        output.push(format!("- **WHOIS Server**: `{}`", response.whois_server));
198
199        output.join("\n")
200    }
201
202    fn format_rdap(&self, response: &RdapResponse) -> String {
203        let mut output = Vec::new();
204
205        let name = response
206            .domain_name()
207            .or(response.name.as_deref())
208            .unwrap_or("Unknown");
209        output.push(format!("## RDAP: {}", name));
210        output.push(String::new());
211
212        if let Some(ref handle) = response.handle {
213            output.push(format!("- **Handle**: `{}`", handle));
214        }
215        if let Some(registrar) = response.get_registrar() {
216            output.push(format!("- **Registrar**: {}", registrar));
217        }
218        if let Some(registrant) = response.get_registrant() {
219            output.push(format!("- **Registrant**: {}", registrant));
220        }
221        if let Some(organization) = response.get_registrant_organization() {
222            output.push(format!("- **Organization**: {}", organization));
223        }
224
225        // Contact sections
226        if let Some(contact) = response.get_registrant_contact() {
227            self.format_rdap_contact(&mut output, "Registrant Contact", &contact);
228        }
229        if let Some(contact) = response.get_admin_contact() {
230            self.format_rdap_contact(&mut output, "Admin Contact", &contact);
231        }
232        if let Some(contact) = response.get_tech_contact() {
233            self.format_rdap_contact(&mut output, "Tech Contact", &contact);
234        }
235        if let Some(contact) = response.get_billing_contact() {
236            self.format_rdap_contact(&mut output, "Billing Contact", &contact);
237        }
238
239        if let Some(created) = response.creation_date() {
240            output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
241        }
242        if let Some(expires) = response.expiration_date() {
243            let days_until = (expires - chrono::Utc::now()).num_days();
244            output.push(format!(
245                "- **Expires**: `{}` ({} days)",
246                expires.format("%Y-%m-%d"),
247                days_until
248            ));
249        }
250        if let Some(updated) = response.last_updated() {
251            output.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
252        }
253
254        if !response.status.is_empty() {
255            output.push(format!(
256                "- **Status**: {}",
257                response
258                    .status
259                    .iter()
260                    .map(|s| format!("`{}`", s))
261                    .collect::<Vec<_>>()
262                    .join(", ")
263            ));
264        }
265
266        let nameservers = response.nameserver_names();
267        if !nameservers.is_empty() {
268            output.push(format!(
269                "- **Nameservers**: {}",
270                nameservers
271                    .iter()
272                    .map(|ns| format!("`{}`", ns))
273                    .collect::<Vec<_>>()
274                    .join(", ")
275            ));
276        }
277
278        if response.is_dnssec_signed() {
279            output.push("- **DNSSEC**: signed".to_string());
280        }
281
282        // IP-specific fields
283        if let Some(ref start) = response.start_address {
284            output.push(format!("- **Start Address**: `{}`", start));
285        }
286        if let Some(ref end) = response.end_address {
287            output.push(format!("- **End Address**: `{}`", end));
288        }
289        if let Some(ref country) = response.country {
290            output.push(format!("- **Country**: {}", country));
291        }
292
293        // ASN-specific fields
294        if let Some(start) = response.start_autnum {
295            output.push(format!(
296                "- **AS Number**: `AS{}` - `AS{}`",
297                start,
298                response.end_autnum.unwrap_or(start)
299            ));
300        }
301
302        output.join("\n")
303    }
304
305    fn format_dns(&self, records: &[DnsRecord]) -> String {
306        let mut output = Vec::new();
307
308        if records.is_empty() {
309            output.push("*No records found*".to_string());
310            output.push(String::new());
311            output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
312            return output.join("\n");
313        }
314
315        let domain = &records[0].name;
316        let record_type = &records[0].record_type;
317        output.push(format!("## DNS {} Records: {}", record_type, domain));
318        output.push(String::new());
319        output.push("| Name | TTL | Type | Data |".to_string());
320        output.push("| --- | --- | --- | --- |".to_string());
321
322        for record in records {
323            output.push(format!(
324                "| `{}` | {} | {} | `{}` |",
325                record.name, record.ttl, record.record_type, record.data
326            ));
327        }
328
329        output.push(String::new());
330        output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
331
332        output.join("\n")
333    }
334
335    fn format_propagation(&self, result: &PropagationResult) -> String {
336        let mut output = Vec::new();
337
338        output.push(format!(
339            "## Propagation: {} {}",
340            result.domain, result.record_type
341        ));
342        output.push(String::new());
343
344        // Summary
345        let percentage = result.propagation_percentage;
346        let status = if percentage >= 100.0 {
347            "Fully propagated"
348        } else if percentage >= 80.0 {
349            "Mostly propagated"
350        } else if percentage >= 50.0 {
351            "Partially propagated"
352        } else {
353            "Not propagated"
354        };
355        output.push(format!("**{:.1}%** - {}", percentage, status));
356        output.push(String::new());
357        output.push(format!(
358            "- **Servers responding**: {}/{}",
359            result.servers_responding, result.servers_checked
360        ));
361
362        // Consensus values
363        if !result.consensus_values.is_empty() {
364            output.push(format!(
365                "- **Consensus values**: {}",
366                result
367                    .consensus_values
368                    .iter()
369                    .map(|v| format!("`{}`", v))
370                    .collect::<Vec<_>>()
371                    .join(", ")
372            ));
373        }
374
375        // Inconsistencies (genuine answer conflicts)
376        if !result.inconsistencies.is_empty() {
377            output.push(String::new());
378            output.push("### Inconsistencies".to_string());
379            output.push(String::new());
380            for inconsistency in &result.inconsistencies {
381                output.push(format!("- {}", inconsistency));
382            }
383        }
384
385        // Unreachable servers (distinct from answer conflicts)
386        if !result.unreachable_servers.is_empty() {
387            output.push(String::new());
388            output.push("### Unreachable servers".to_string());
389            output.push(String::new());
390            for unreachable in &result.unreachable_servers {
391                let error_msg = unreachable.error.as_deref().unwrap_or("no response");
392                output.push(format!(
393                    "- **{}** (`{}`): {}",
394                    unreachable.name, unreachable.ip, error_msg
395                ));
396            }
397        }
398
399        // Results table
400        output.push(String::new());
401        output.push("### Results".to_string());
402        output.push(String::new());
403        output.push("| Server | Location | IP | Result | Time |".to_string());
404        output.push("| --- | --- | --- | --- | --- |".to_string());
405
406        for sr in &result.results {
407            let result_str = if sr.success {
408                if sr.records.is_empty() {
409                    "NXDOMAIN".to_string()
410                } else {
411                    sr.records
412                        .iter()
413                        .map(|r| r.format_short())
414                        .collect::<Vec<_>>()
415                        .join(", ")
416                }
417            } else {
418                sr.error.as_deref().unwrap_or("Error").to_string()
419            };
420
421            output.push(format!(
422                "| {} | {} | `{}` | `{}` | {}ms |",
423                sr.server.name, sr.server.location, sr.server.ip, result_str, sr.response_time_ms
424            ));
425        }
426
427        if !result.dnssec_validated {
428            output.push(String::new());
429            output.push("> Note: DNS responses are not DNSSEC-validated.".to_string());
430        }
431
432        output.join("\n")
433    }
434
435    fn format_lookup(&self, result: &LookupResult) -> String {
436        let mut output = Vec::new();
437
438        let domain = result
439            .domain_name()
440            .unwrap_or_else(|| "Unknown".to_string());
441        let source = match result {
442            LookupResult::Rdap { .. } => "RDAP",
443            LookupResult::Whois { .. } => "WHOIS",
444            LookupResult::Available { .. } => "availability",
445        };
446
447        output.push(format!("## Lookup: {}", domain));
448        output.push(String::new());
449        output.push(format!("- **Source**: {}", source));
450
451        match result {
452            LookupResult::Rdap {
453                data,
454                whois_fallback,
455            } => {
456                if let Some(registrar) = data.get_registrar() {
457                    output.push(format!("- **Registrar**: {}", registrar));
458                }
459                if let Some(registrant) = data.get_registrant() {
460                    output.push(format!("- **Registrant**: {}", registrant));
461                }
462                if let Some(organization) = data.get_registrant_organization() {
463                    output.push(format!("- **Organization**: {}", organization));
464                }
465
466                // Contact sections from RDAP
467                if let Some(contact) = data.get_registrant_contact() {
468                    self.format_rdap_contact(&mut output, "Registrant Contact", &contact);
469                }
470                if let Some(contact) = data.get_admin_contact() {
471                    self.format_rdap_contact(&mut output, "Admin Contact", &contact);
472                }
473                if let Some(contact) = data.get_tech_contact() {
474                    self.format_rdap_contact(&mut output, "Tech Contact", &contact);
475                }
476
477                if let Some(created) = data.creation_date() {
478                    output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
479                }
480                if let Some(expires) = data.expiration_date() {
481                    let days_until = (expires - chrono::Utc::now()).num_days();
482                    output.push(format!(
483                        "- **Expires**: `{}` ({} days)",
484                        expires.format("%Y-%m-%d"),
485                        days_until
486                    ));
487                }
488
489                if !data.status.is_empty() {
490                    output.push(format!(
491                        "- **Status**: {}",
492                        data.status
493                            .iter()
494                            .map(|s| format!("`{}`", s))
495                            .collect::<Vec<_>>()
496                            .join(", ")
497                    ));
498                }
499
500                let nameservers = data.nameserver_names();
501                if !nameservers.is_empty() {
502                    output.push(format!(
503                        "- **Nameservers**: {}",
504                        nameservers
505                            .iter()
506                            .map(|ns| format!("`{}`", ns))
507                            .collect::<Vec<_>>()
508                            .join(", ")
509                    ));
510                }
511
512                if data.is_dnssec_signed() {
513                    output.push("- **DNSSEC**: signed".to_string());
514                }
515
516                // WHOIS fallback data
517                if let Some(whois) = whois_fallback {
518                    let mut extra = Vec::new();
519
520                    if data.get_registrant().is_none() {
521                        if let Some(ref registrant) = whois.registrant {
522                            extra.push(format!("- **Registrant**: {}", registrant));
523                        }
524                    }
525                    if data.get_registrant_organization().is_none() {
526                        if let Some(ref org) = whois.organization {
527                            extra.push(format!("- **Organization**: {}", org));
528                        }
529                    }
530
531                    // Registrant contact from WHOIS fallback
532                    let rdap_has_registrant = data
533                        .get_registrant_contact()
534                        .as_ref()
535                        .is_some_and(|c| c.has_info());
536                    if !rdap_has_registrant {
537                        let has_whois_contact = whois.registrant_email.is_some()
538                            || whois.registrant_phone.is_some()
539                            || whois.registrant_address.is_some()
540                            || whois.registrant_country.is_some();
541                        if has_whois_contact {
542                            extra.push(String::new());
543                            extra.push("### Registrant Contact".to_string());
544                            extra.push(String::new());
545                            if let Some(ref email) = whois.registrant_email {
546                                extra.push(format!("- **Email**: `{}`", email));
547                            }
548                            if let Some(ref phone) = whois.registrant_phone {
549                                extra.push(format!("- **Phone**: {}", phone));
550                            }
551                            if let Some(ref address) = whois.registrant_address {
552                                extra.push(format!("- **Address**: {}", address));
553                            }
554                            if let Some(ref country) = whois.registrant_country {
555                                extra.push(format!("- **Country**: {}", country));
556                            }
557                        }
558                    }
559
560                    // Admin contact from WHOIS fallback
561                    let rdap_has_admin = data.get_admin_contact().is_some_and(|c| c.has_info());
562                    if !rdap_has_admin {
563                        let has_whois_admin = whois.admin_name.is_some()
564                            || whois.admin_email.is_some()
565                            || whois.admin_phone.is_some();
566                        if has_whois_admin {
567                            extra.push(String::new());
568                            extra.push("### Admin Contact".to_string());
569                            extra.push(String::new());
570                            if let Some(ref name) = whois.admin_name {
571                                extra.push(format!("- **Name**: {}", name));
572                            }
573                            if let Some(ref org) = whois.admin_organization {
574                                extra.push(format!("- **Organization**: {}", org));
575                            }
576                            if let Some(ref email) = whois.admin_email {
577                                extra.push(format!("- **Email**: `{}`", email));
578                            }
579                            if let Some(ref phone) = whois.admin_phone {
580                                extra.push(format!("- **Phone**: {}", phone));
581                            }
582                        }
583                    }
584
585                    // Tech contact from WHOIS fallback
586                    let rdap_has_tech = data.get_tech_contact().is_some_and(|c| c.has_info());
587                    if !rdap_has_tech {
588                        let has_whois_tech = whois.tech_name.is_some()
589                            || whois.tech_email.is_some()
590                            || whois.tech_phone.is_some();
591                        if has_whois_tech {
592                            extra.push(String::new());
593                            extra.push("### Tech Contact".to_string());
594                            extra.push(String::new());
595                            if let Some(ref name) = whois.tech_name {
596                                extra.push(format!("- **Name**: {}", name));
597                            }
598                            if let Some(ref org) = whois.tech_organization {
599                                extra.push(format!("- **Organization**: {}", org));
600                            }
601                            if let Some(ref email) = whois.tech_email {
602                                extra.push(format!("- **Email**: `{}`", email));
603                            }
604                            if let Some(ref phone) = whois.tech_phone {
605                                extra.push(format!("- **Phone**: {}", phone));
606                            }
607                        }
608                    }
609
610                    if let Some(updated) = whois.updated_date {
611                        extra.push(format!("- **Updated**: `{}`", updated.format("%Y-%m-%d")));
612                    }
613
614                    if !data.is_dnssec_signed() {
615                        if let Some(ref dnssec) = whois.dnssec {
616                            extra.push(format!("- **DNSSEC**: {}", dnssec));
617                        }
618                    }
619
620                    if !whois.whois_server.is_empty() {
621                        extra.push(format!("- **WHOIS Server**: `{}`", whois.whois_server));
622                    }
623
624                    if !extra.is_empty() {
625                        output.push(String::new());
626                        output.push("### Additional WHOIS Data".to_string());
627                        output.push(String::new());
628                        output.extend(extra);
629                    }
630                }
631            }
632            LookupResult::Whois {
633                data, rdap_error, ..
634            } => {
635                if let Some(ref error) = rdap_error {
636                    output.push(format!("- **RDAP Error**: {}", error));
637                }
638
639                if let Some(ref registrar) = data.registrar {
640                    output.push(format!("- **Registrar**: {}", registrar));
641                }
642                if let Some(ref registrant) = data.registrant {
643                    output.push(format!("- **Registrant**: {}", registrant));
644                }
645                if let Some(ref organization) = data.organization {
646                    output.push(format!("- **Organization**: {}", organization));
647                }
648
649                // Registrant contact details
650                let has_registrant_details = data.registrant_email.is_some()
651                    || data.registrant_phone.is_some()
652                    || data.registrant_address.is_some()
653                    || data.registrant_country.is_some();
654
655                if has_registrant_details {
656                    output.push(String::new());
657                    output.push("### Registrant Contact".to_string());
658                    output.push(String::new());
659                    if let Some(ref email) = data.registrant_email {
660                        output.push(format!("- **Email**: `{}`", email));
661                    }
662                    if let Some(ref phone) = data.registrant_phone {
663                        output.push(format!("- **Phone**: {}", phone));
664                    }
665                    if let Some(ref address) = data.registrant_address {
666                        output.push(format!("- **Address**: {}", address));
667                    }
668                    if let Some(ref country) = data.registrant_country {
669                        output.push(format!("- **Country**: {}", country));
670                    }
671                }
672
673                // Admin contact
674                self.format_whois_contact(
675                    &mut output,
676                    "Admin Contact",
677                    &data.admin_name,
678                    &data.admin_organization,
679                    &data.admin_email,
680                    &data.admin_phone,
681                );
682
683                // Tech contact
684                self.format_whois_contact(
685                    &mut output,
686                    "Tech Contact",
687                    &data.tech_name,
688                    &data.tech_organization,
689                    &data.tech_email,
690                    &data.tech_phone,
691                );
692
693                if let Some(created) = data.creation_date {
694                    output.push(format!("- **Created**: `{}`", created.format("%Y-%m-%d")));
695                }
696                if let Some(expires) = data.expiration_date {
697                    let days_until = (expires - chrono::Utc::now()).num_days();
698                    output.push(format!(
699                        "- **Expires**: `{}` ({} days)",
700                        expires.format("%Y-%m-%d"),
701                        days_until
702                    ));
703                }
704
705                if !data.status.is_empty() {
706                    output.push(format!(
707                        "- **Status**: {}",
708                        data.status
709                            .iter()
710                            .map(|s| format!("`{}`", s))
711                            .collect::<Vec<_>>()
712                            .join(", ")
713                    ));
714                }
715
716                if !data.nameservers.is_empty() {
717                    output.push(format!(
718                        "- **Nameservers**: {}",
719                        data.nameservers
720                            .iter()
721                            .map(|ns| format!("`{}`", ns))
722                            .collect::<Vec<_>>()
723                            .join(", ")
724                    ));
725                }
726
727                if let Some(ref dnssec) = data.dnssec {
728                    output.push(format!("- **DNSSEC**: {}", dnssec));
729                }
730            }
731            LookupResult::Available {
732                data,
733                rdap_error,
734                whois_error,
735                whois_data,
736            } => {
737                let verdict = match data.confidence.as_str() {
738                    "high" => "AVAILABLE",
739                    "medium" => "MAY BE AVAILABLE",
740                    _ => "UNKNOWN",
741                };
742                output.push(format!("- **Verdict**: {}", verdict));
743                output.push(format!("- **Confidence**: {}", data.confidence));
744                output.push(format!("- **Method**: {}", data.method));
745                if let Some(ref details) = data.details {
746                    output.push(format!("- **Details**: {}", details));
747                }
748                if !rdap_error.is_empty() {
749                    output.push(format!("- **RDAP Error**: {}", rdap_error));
750                }
751                if !whois_error.is_empty() {
752                    output.push(format!("- **WHOIS Error**: {}", whois_error));
753                }
754
755                if let Some(w) = whois_data {
756                    let mut bullets = Vec::new();
757                    if !w.nameservers.is_empty() {
758                        bullets.push(format!(
759                            "- **Nameservers**: {}",
760                            w.nameservers
761                                .iter()
762                                .map(|ns| format!("`{}`", ns))
763                                .collect::<Vec<_>>()
764                                .join(", ")
765                        ));
766                    }
767                    if !w.status.is_empty() {
768                        bullets.push(format!(
769                            "- **Status**: {}",
770                            w.status
771                                .iter()
772                                .map(|s| format!("`{}`", s))
773                                .collect::<Vec<_>>()
774                                .join(", ")
775                        ));
776                    }
777                    if let Some(ref dnssec) = w.dnssec {
778                        bullets.push(format!("- **DNSSEC**: {}", dnssec));
779                    }
780                    if !w.whois_server.is_empty() {
781                        bullets.push(format!("- **WHOIS Server**: `{}`", w.whois_server));
782                    }
783                    if !bullets.is_empty() {
784                        output.push(String::new());
785                        output.push("### Additional WHOIS data".to_string());
786                        output.push(String::new());
787                        output.extend(bullets);
788                    }
789                }
790            }
791        }
792
793        output.join("\n")
794    }
795
796    fn format_status(&self, response: &StatusResponse) -> String {
797        let mut output = Vec::new();
798
799        output.push(format!("## Status: {}", response.domain));
800        output.push(String::new());
801
802        // HTTP Status
803        if let Some(status) = response.http_status {
804            let status_text = response.http_status_text.as_deref().unwrap_or("Unknown");
805            output.push(format!("- **HTTP Status**: `{}` ({})", status, status_text));
806        }
807
808        // Site Title
809        if let Some(ref title) = response.title {
810            output.push(format!("- **Site Title**: {}", title));
811        }
812
813        // SSL Certificate
814        output.push(String::new());
815        if let Some(ref cert) = response.certificate {
816            output.push("### SSL Certificate".to_string());
817            output.push(String::new());
818            output.push(format!("- **Subject**: `{}`", cert.subject));
819            output.push(format!("- **Issuer**: {}", cert.issuer));
820            output.push(format!(
821                "- **Status**: {}",
822                if cert.is_valid { "Valid" } else { "Invalid" }
823            ));
824            output.push(format!(
825                "- **Valid From**: `{}`",
826                cert.valid_from.format("%Y-%m-%d")
827            ));
828            output.push(format!(
829                "- **Expires**: `{}` ({} days)",
830                cert.valid_until.format("%Y-%m-%d"),
831                cert.days_until_expiry
832            ));
833        } else {
834            output.push("### SSL Certificate".to_string());
835            output.push(String::new());
836            output.push("*Not available (HTTPS may not be configured)*".to_string());
837        }
838
839        // Domain Expiration
840        if let Some(ref expiry) = response.domain_expiration {
841            output.push(String::new());
842            output.push("### Domain Registration".to_string());
843            output.push(String::new());
844            if let Some(ref registrar) = expiry.registrar {
845                output.push(format!("- **Registrar**: {}", registrar));
846            }
847            output.push(format!(
848                "- **Expires**: `{}` ({} days)",
849                expiry.expiration_date.format("%Y-%m-%d"),
850                expiry.days_until_expiry
851            ));
852        }
853
854        // DNS Resolution
855        output.push(String::new());
856        if let Some(ref dns) = response.dns_resolution {
857            output.push("### DNS Resolution".to_string());
858            output.push(String::new());
859            output.push(format!(
860                "- **Resolves**: {}",
861                if dns.resolves { "Yes" } else { "No" }
862            ));
863
864            if let Some(ref cname) = dns.cname_target {
865                output.push(format!("- **CNAME**: `{}`", cname));
866            }
867            if !dns.a_records.is_empty() {
868                output.push(format!(
869                    "- **IPv4 (A)**: {}",
870                    dns.a_records
871                        .iter()
872                        .map(|ip| format!("`{}`", ip))
873                        .collect::<Vec<_>>()
874                        .join(", ")
875                ));
876            }
877            if !dns.aaaa_records.is_empty() {
878                output.push(format!(
879                    "- **IPv6 (AAAA)**: {}",
880                    dns.aaaa_records
881                        .iter()
882                        .map(|ip| format!("`{}`", ip))
883                        .collect::<Vec<_>>()
884                        .join(", ")
885                ));
886            }
887            if !dns.nameservers.is_empty() {
888                output.push(format!(
889                    "- **Nameservers**: {}",
890                    dns.nameservers
891                        .iter()
892                        .map(|ns| format!("`{}`", ns))
893                        .collect::<Vec<_>>()
894                        .join(", ")
895                ));
896            }
897        } else {
898            output.push("### DNS Resolution".to_string());
899            output.push(String::new());
900            output.push("*Check failed*".to_string());
901        }
902
903        output.join("\n")
904    }
905
906    fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
907        let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
908
909        if let Some(ref error) = iteration.error {
910            return format!(
911                "[{}] Iteration {}/{}: **ERROR** - {}",
912                time_str, iteration.iteration, iteration.total_iterations, error
913            );
914        }
915
916        let record_count = iteration.record_count();
917        let status = if iteration.iteration == 1 {
918            String::new()
919        } else if iteration.changed {
920            " (**CHANGED**)".to_string()
921        } else {
922            " (unchanged)".to_string()
923        };
924
925        let values: Vec<String> = iteration
926            .records
927            .iter()
928            .map(|r| r.data.to_string().trim_end_matches('.').to_string())
929            .collect();
930
931        let values_str = if values.is_empty() {
932            String::new()
933        } else {
934            format!(" `{}`", values.join(", "))
935        };
936
937        format!(
938            "[{}] Iteration {}/{}: {} record(s){}{}",
939            time_str,
940            iteration.iteration,
941            iteration.total_iterations,
942            record_count,
943            status,
944            values_str
945        )
946    }
947
948    fn format_follow(&self, result: &FollowResult) -> String {
949        let mut output = Vec::new();
950
951        output.push(format!(
952            "## DNS Follow: {} {}",
953            result.domain, result.record_type
954        ));
955        output.push(String::new());
956
957        // Summary
958        output.push(format!(
959            "- **Iterations**: {}/{}",
960            result.completed_iterations(),
961            result.iterations_requested
962        ));
963
964        if result.interrupted {
965            output.push("- **Status**: Interrupted".to_string());
966        }
967
968        output.push(format!("- **Total changes**: {}", result.total_changes));
969
970        let duration = result.ended_at - result.started_at;
971        let total_secs = duration.num_seconds();
972        let duration_str = if total_secs < 60 {
973            format!("{}s", total_secs)
974        } else if total_secs < 3600 {
975            format!("{}m {}s", total_secs / 60, total_secs % 60)
976        } else {
977            format!("{}h {}m", total_secs / 3600, (total_secs % 3600) / 60)
978        };
979        output.push(format!("- **Duration**: {}", duration_str));
980
981        // Iteration details table
982        if !result.iterations.is_empty() {
983            output.push(String::new());
984            output.push("### Iteration Details".to_string());
985            output.push(String::new());
986            output.push("| # | Time | Records | Status |".to_string());
987            output.push("| --- | --- | --- | --- |".to_string());
988
989            for iteration in &result.iterations {
990                let time_str = iteration.timestamp.format("%H:%M:%S").to_string();
991                let status = if iteration.error.is_some() {
992                    "ERROR"
993                } else if iteration.changed {
994                    "CHANGED"
995                } else if iteration.iteration == 1 {
996                    "initial"
997                } else {
998                    "stable"
999                };
1000
1001                output.push(format!(
1002                    "| {} | {} | {} | {} |",
1003                    iteration.iteration,
1004                    time_str,
1005                    iteration.record_count(),
1006                    status
1007                ));
1008            }
1009        }
1010
1011        output.join("\n")
1012    }
1013
1014    fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
1015        let mut output = Vec::new();
1016
1017        output.push(format!("## Availability: {}", result.domain));
1018        output.push(String::new());
1019
1020        let avail_str = if result.available {
1021            "**AVAILABLE**"
1022        } else {
1023            "**TAKEN**"
1024        };
1025        output.push(format!("- **Result**: {}", avail_str));
1026        output.push(format!("- **Confidence**: {}", result.confidence));
1027        output.push(format!("- **Method**: {}", result.method));
1028        if let Some(ref details) = result.details {
1029            output.push(format!("- **Details**: {}", details));
1030        }
1031
1032        output.join("\n")
1033    }
1034
1035    fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
1036        let mut output = Vec::new();
1037
1038        output.push(format!("## TLD Info: .{}", info.tld));
1039        output.push(String::new());
1040
1041        output.push(format!("- **Type**: {}", info.tld_type));
1042
1043        match info.whois_server {
1044            Some(ref server) => output.push(format!("- **WHOIS Server**: `{}`", server)),
1045            None => output.push("- **WHOIS Server**: *not available*".to_string()),
1046        }
1047
1048        match info.rdap_url {
1049            Some(ref url) => output.push(format!("- **RDAP URL**: `{}`", url)),
1050            None => output.push("- **RDAP URL**: *not available*".to_string()),
1051        }
1052
1053        match info.registry_url {
1054            Some(ref url) => output.push(format!("- **Registry URL**: {}", url)),
1055            None => output.push("- **Registry URL**: *not available*".to_string()),
1056        }
1057
1058        output.join("\n")
1059    }
1060
1061    fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
1062        let mut output = Vec::new();
1063
1064        output.push(format!("## DNSSEC: {}", report.domain));
1065        output.push(String::new());
1066
1067        output.push(format!("- **Status**: `{}`", report.status));
1068        output.push(format!(
1069            "- **Chain Valid**: {}",
1070            if report.chain_valid { "yes" } else { "no" }
1071        ));
1072        output.push(format!("- **Enabled**: {}", report.enabled));
1073        output.push(format!("- **DS Records**: {}", report.ds_records.len()));
1074        output.push(format!(
1075            "- **DNSKEY Records**: {}",
1076            report.dnskey_records.len()
1077        ));
1078
1079        if !report.ds_records.is_empty() {
1080            output.push(String::new());
1081            output.push("### DS Records".to_string());
1082            output.push(String::new());
1083            output.push("| Key Tag | Algorithm | Digest Type | Matched | Verified |".to_string());
1084            output.push("| --- | --- | --- | --- | --- |".to_string());
1085            for ds in &report.ds_records {
1086                output.push(format!(
1087                    "| {} | {} ({}) | {} ({}) | {} | {} |",
1088                    ds.key_tag,
1089                    ds.algorithm,
1090                    ds.algorithm_name,
1091                    ds.digest_type,
1092                    ds.digest_type_name,
1093                    if ds.matched_key { "yes" } else { "no" },
1094                    if ds.digest_verified { "yes" } else { "no" },
1095                ));
1096            }
1097        }
1098
1099        if !report.dnskey_records.is_empty() {
1100            output.push(String::new());
1101            output.push("### DNSKEY Records".to_string());
1102            output.push(String::new());
1103            output.push("| Key Tag | Flags | Role | Algorithm |".to_string());
1104            output.push("| --- | --- | --- | --- |".to_string());
1105            for key in &report.dnskey_records {
1106                let role = if key.is_ksk {
1107                    "KSK"
1108                } else if key.is_zsk {
1109                    "ZSK"
1110                } else {
1111                    "Other"
1112                };
1113                output.push(format!(
1114                    "| {} | {} | {} | {} ({}) |",
1115                    key.key_tag, key.flags, role, key.algorithm, key.algorithm_name
1116                ));
1117            }
1118        }
1119
1120        if !report.issues.is_empty() {
1121            output.push(String::new());
1122            output.push("### Issues".to_string());
1123            output.push(String::new());
1124            for issue in &report.issues {
1125                output.push(format!("- {}", issue));
1126            }
1127        }
1128
1129        output.join("\n")
1130    }
1131
1132    fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
1133        let mut output = Vec::new();
1134
1135        output.push(format!(
1136            "## DNS Comparison: {} {}",
1137            comparison.domain, comparison.record_type
1138        ));
1139        output.push(String::new());
1140
1141        if comparison.matches {
1142            output.push("**Result**: Records match".to_string());
1143        } else {
1144            output.push("**Result**: Records differ".to_string());
1145        }
1146        output.push(String::new());
1147
1148        // Server A
1149        output.push(format!("### Server A ({})", comparison.server_a.nameserver));
1150        output.push(String::new());
1151        if let Some(ref err) = comparison.server_a.error {
1152            output.push(format!("**Error**: {}", err));
1153        } else if comparison.server_a.records.is_empty() {
1154            output.push("*No records found*".to_string());
1155        } else {
1156            output.push("| Record |".to_string());
1157            output.push("| --- |".to_string());
1158            for record in &comparison.server_a.records {
1159                output.push(format!("| `{}` |", record.format_short()));
1160            }
1161        }
1162        output.push(String::new());
1163
1164        // Server B
1165        output.push(format!("### Server B ({})", comparison.server_b.nameserver));
1166        output.push(String::new());
1167        if let Some(ref err) = comparison.server_b.error {
1168            output.push(format!("**Error**: {}", err));
1169        } else if comparison.server_b.records.is_empty() {
1170            output.push("*No records found*".to_string());
1171        } else {
1172            output.push("| Record |".to_string());
1173            output.push("| --- |".to_string());
1174            for record in &comparison.server_b.records {
1175                output.push(format!("| `{}` |", record.format_short()));
1176            }
1177        }
1178        output.push(String::new());
1179
1180        // Differences
1181        output.push("### Comparison".to_string());
1182        output.push(String::new());
1183
1184        if comparison.common.is_empty() {
1185            output.push("- **Common**: *(none)*".to_string());
1186        } else {
1187            output.push(format!(
1188                "- **Common**: {}",
1189                comparison
1190                    .common
1191                    .iter()
1192                    .map(|r| format!("`{}`", r))
1193                    .collect::<Vec<_>>()
1194                    .join(", ")
1195            ));
1196        }
1197
1198        if comparison.only_in_a.is_empty() {
1199            output.push(format!(
1200                "- **Only in {}**: *(none)*",
1201                comparison.server_a.nameserver
1202            ));
1203        } else {
1204            output.push(format!(
1205                "- **Only in {}**: {}",
1206                comparison.server_a.nameserver,
1207                comparison
1208                    .only_in_a
1209                    .iter()
1210                    .map(|r| format!("`{}`", r))
1211                    .collect::<Vec<_>>()
1212                    .join(", ")
1213            ));
1214        }
1215
1216        if comparison.only_in_b.is_empty() {
1217            output.push(format!(
1218                "- **Only in {}**: *(none)*",
1219                comparison.server_b.nameserver
1220            ));
1221        } else {
1222            output.push(format!(
1223                "- **Only in {}**: {}",
1224                comparison.server_b.nameserver,
1225                comparison
1226                    .only_in_b
1227                    .iter()
1228                    .map(|r| format!("`{}`", r))
1229                    .collect::<Vec<_>>()
1230                    .join(", ")
1231            ));
1232        }
1233
1234        output.join("\n")
1235    }
1236
1237    fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
1238        let mut output = Vec::new();
1239
1240        output.push(format!("## Subdomains: {}", result.domain));
1241        output.push(String::new());
1242        output.push(format!("- **Source**: {}", result.source));
1243        output.push(format!("- **Count**: {}", result.count));
1244        output.push(String::new());
1245
1246        if result.subdomains.is_empty() {
1247            output.push("*No subdomains found*".to_string());
1248        } else {
1249            for subdomain in &result.subdomains {
1250                output.push(format!("- `{}`", subdomain));
1251            }
1252        }
1253
1254        output.join("\n")
1255    }
1256
1257    fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
1258        let dash = "\u{2014}";
1259        let mut output = Vec::new();
1260
1261        output.push(format!(
1262            "## Domain Comparison: {} vs {}",
1263            diff.domain_a, diff.domain_b
1264        ));
1265        output.push(String::new());
1266
1267        // Registration table
1268        output.push("### Registration".to_string());
1269        output.push(String::new());
1270        output.push(format!("| Field | {} | {} |", diff.domain_a, diff.domain_b));
1271        output.push("| --- | --- | --- |".to_string());
1272
1273        let reg = &diff.registration;
1274        output.push(format!(
1275            "| Registrar | {} | {} |",
1276            reg.registrar.0.as_deref().unwrap_or(dash),
1277            reg.registrar.1.as_deref().unwrap_or(dash)
1278        ));
1279        output.push(format!(
1280            "| Organization | {} | {} |",
1281            reg.organization.0.as_deref().unwrap_or(dash),
1282            reg.organization.1.as_deref().unwrap_or(dash)
1283        ));
1284        output.push(format!(
1285            "| Created | {} | {} |",
1286            reg.created.0.as_deref().unwrap_or(dash),
1287            reg.created.1.as_deref().unwrap_or(dash)
1288        ));
1289        output.push(format!(
1290            "| Expires | {} | {} |",
1291            reg.expires.0.as_deref().unwrap_or(dash),
1292            reg.expires.1.as_deref().unwrap_or(dash)
1293        ));
1294
1295        // DNS table
1296        output.push(String::new());
1297        output.push("### DNS".to_string());
1298        output.push(String::new());
1299        output.push(format!("| Field | {} | {} |", diff.domain_a, diff.domain_b));
1300        output.push("| --- | --- | --- |".to_string());
1301        let dns = &diff.dns;
1302        output.push(format!(
1303            "| Resolves | {} | {} |",
1304            if dns.resolves.0 { "yes" } else { "no" },
1305            if dns.resolves.1 { "yes" } else { "no" }
1306        ));
1307        let a_recs_a = if dns.a_records.0.is_empty() {
1308            dash.to_string()
1309        } else {
1310            format!("`{}`", dns.a_records.0.join("`, `"))
1311        };
1312        let a_recs_b = if dns.a_records.1.is_empty() {
1313            dash.to_string()
1314        } else {
1315            format!("`{}`", dns.a_records.1.join("`, `"))
1316        };
1317        output.push(format!("| A Records | {} | {} |", a_recs_a, a_recs_b));
1318        let ns_a = if dns.nameservers.0.is_empty() {
1319            dash.to_string()
1320        } else {
1321            format!("`{}`", dns.nameservers.0.join("`, `"))
1322        };
1323        let ns_b = if dns.nameservers.1.is_empty() {
1324            dash.to_string()
1325        } else {
1326            format!("`{}`", dns.nameservers.1.join("`, `"))
1327        };
1328        output.push(format!("| Nameservers | {} | {} |", ns_a, ns_b));
1329
1330        // SSL table
1331        output.push(String::new());
1332        output.push("### SSL".to_string());
1333        output.push(String::new());
1334        output.push(format!("| Field | {} | {} |", diff.domain_a, diff.domain_b));
1335        output.push("| --- | --- | --- |".to_string());
1336        let ssl = &diff.ssl;
1337        output.push(format!(
1338            "| Issuer | {} | {} |",
1339            ssl.issuer.0.as_deref().unwrap_or(dash),
1340            ssl.issuer.1.as_deref().unwrap_or(dash)
1341        ));
1342        output.push(format!(
1343            "| Valid Until | {} | {} |",
1344            ssl.valid_until.0.as_deref().unwrap_or(dash),
1345            ssl.valid_until.1.as_deref().unwrap_or(dash)
1346        ));
1347        output.push(format!(
1348            "| Days Remaining | {} | {} |",
1349            ssl.days_remaining
1350                .0
1351                .map(|d| d.to_string())
1352                .as_deref()
1353                .unwrap_or(dash),
1354            ssl.days_remaining
1355                .1
1356                .map(|d| d.to_string())
1357                .as_deref()
1358                .unwrap_or(dash)
1359        ));
1360        output.push(format!(
1361            "| Valid | {} | {} |",
1362            ssl.is_valid
1363                .0
1364                .map(|v| if v { "yes" } else { "no" })
1365                .unwrap_or(dash),
1366            ssl.is_valid
1367                .1
1368                .map(|v| if v { "yes" } else { "no" })
1369                .unwrap_or(dash)
1370        ));
1371
1372        output.join("\n")
1373    }
1374
1375    fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
1376        let mut output = Vec::new();
1377
1378        output.push(format!("## SSL Report: {}", report.domain));
1379        output.push(String::new());
1380
1381        output.push(format!(
1382            "- **Valid**: {}",
1383            if report.is_valid { "yes" } else { "no" }
1384        ));
1385        output.push(format!(
1386            "- **Days Until Expiry**: {}",
1387            report.days_until_expiry
1388        ));
1389
1390        if let Some(ref proto) = report.protocol_version {
1391            output.push(format!("- **Protocol**: {}", proto));
1392        }
1393
1394        if !report.san_names.is_empty() {
1395            output.push(format!(
1396                "- **SANs**: {}",
1397                report
1398                    .san_names
1399                    .iter()
1400                    .map(|s| format!("`{}`", s))
1401                    .collect::<Vec<_>>()
1402                    .join(", ")
1403            ));
1404        }
1405
1406        if !report.chain.is_empty() {
1407            output.push(String::new());
1408            output.push("### Certificate Chain".to_string());
1409            output.push(String::new());
1410            output.push("| # | Subject | Issuer | Valid Until | Key |".to_string());
1411            output.push("| --- | --- | --- | --- | --- |".to_string());
1412            for (i, cert) in report.chain.iter().enumerate() {
1413                let key_info = match (&cert.key_type, cert.key_bits) {
1414                    (Some(kt), Some(bits)) => format!("{} ({} bits)", kt, bits),
1415                    (Some(kt), None) => kt.clone(),
1416                    _ => "N/A".to_string(),
1417                };
1418                output.push(format!(
1419                    "| {} | {} | {} | {} | {} |",
1420                    i,
1421                    cert.subject,
1422                    cert.issuer,
1423                    cert.valid_until.format("%Y-%m-%d"),
1424                    key_info
1425                ));
1426            }
1427        }
1428
1429        output.join("\n")
1430    }
1431
1432    fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
1433        let mut output = Vec::new();
1434
1435        output.push("## Domain Watch Report".to_string());
1436        output.push(String::new());
1437        output.push(format!(
1438            "- **Checked**: {}",
1439            report.checked_at.format("%Y-%m-%d %H:%M:%S UTC")
1440        ));
1441        output.push(format!(
1442            "- **Total**: {} domains, {} warnings, {} critical",
1443            report.total, report.warnings, report.critical
1444        ));
1445        output.push(String::new());
1446
1447        if report.results.is_empty() {
1448            output.push("No domains in watchlist.".to_string());
1449            return output.join("\n");
1450        }
1451
1452        output.push("| Status | Domain | SSL Days | Domain Days | HTTP | Issues |".to_string());
1453        output.push("| --- | --- | --- | --- | --- | --- |".to_string());
1454
1455        for r in &report.results {
1456            let icon = if r.issues.is_empty() { "ok" } else { "warn" };
1457            let ssl = r
1458                .ssl_days_remaining
1459                .map(|d| d.to_string())
1460                .unwrap_or_else(|| "N/A".to_string());
1461            let dom = r
1462                .domain_days_remaining
1463                .map(|d| d.to_string())
1464                .unwrap_or_else(|| "N/A".to_string());
1465            let http = r
1466                .http_status
1467                .map(|s| s.to_string())
1468                .unwrap_or_else(|| "N/A".to_string());
1469            let issues = if r.issues.is_empty() {
1470                "-".to_string()
1471            } else {
1472                r.issues.join("; ")
1473            };
1474            output.push(format!(
1475                "| {} | {} | {} | {} | {} | {} |",
1476                icon, r.domain, ssl, dom, http, issues
1477            ));
1478        }
1479
1480        output.join("\n")
1481    }
1482
1483    fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
1484        let mut output = Vec::new();
1485
1486        let source_str = match info.source {
1487            crate::domain_info::DomainInfoSource::Both => "both",
1488            crate::domain_info::DomainInfoSource::Rdap => "rdap",
1489            crate::domain_info::DomainInfoSource::Whois => "whois",
1490            crate::domain_info::DomainInfoSource::Available => "available",
1491        };
1492
1493        output.push(format!("## Domain Info: {}", info.domain));
1494        output.push(String::new());
1495        output.push(format!("**Source:** {}", source_str));
1496        output.push(String::new());
1497
1498        // Registration table
1499        output.push("### Registration".to_string());
1500        output.push(String::new());
1501        output.push("| Field | Value |".to_string());
1502        output.push("| --- | --- |".to_string());
1503        output.push(format!(
1504            "| Registrar | {} |",
1505            info.registrar.as_deref().unwrap_or("-")
1506        ));
1507        output.push(format!(
1508            "| Registrant | {} |",
1509            info.registrant.as_deref().unwrap_or("-")
1510        ));
1511        output.push(format!(
1512            "| Organization | {} |",
1513            info.organization.as_deref().unwrap_or("-")
1514        ));
1515        output.push(format!(
1516            "| Created | {} |",
1517            info.creation_date
1518                .map(|d| d.format("%Y-%m-%d").to_string())
1519                .as_deref()
1520                .unwrap_or("-")
1521        ));
1522        output.push(format!(
1523            "| Expires | {} |",
1524            info.expiration_date
1525                .map(|d| d.format("%Y-%m-%d").to_string())
1526                .as_deref()
1527                .unwrap_or("-")
1528        ));
1529        output.push(format!(
1530            "| Updated | {} |",
1531            info.updated_date
1532                .map(|d| d.format("%Y-%m-%d").to_string())
1533                .as_deref()
1534                .unwrap_or("-")
1535        ));
1536        output.push(format!(
1537            "| Nameservers | {} |",
1538            if info.nameservers.is_empty() {
1539                "-".to_string()
1540            } else {
1541                info.nameservers
1542                    .iter()
1543                    .map(|ns| format!("`{}`", ns))
1544                    .collect::<Vec<_>>()
1545                    .join(", ")
1546            }
1547        ));
1548        output.push(format!(
1549            "| Status | {} |",
1550            if info.status.is_empty() {
1551                "-".to_string()
1552            } else {
1553                info.status
1554                    .iter()
1555                    .map(|s| format!("`{}`", s))
1556                    .collect::<Vec<_>>()
1557                    .join(", ")
1558            }
1559        ));
1560        output.push(format!(
1561            "| DNSSEC | {} |",
1562            info.dnssec.as_deref().unwrap_or("-")
1563        ));
1564
1565        // Contacts table
1566        let has_any_contact = info.registrant_email.is_some()
1567            || info.registrant_phone.is_some()
1568            || info.registrant_address.is_some()
1569            || info.registrant_country.is_some()
1570            || info.admin_name.is_some()
1571            || info.admin_organization.is_some()
1572            || info.admin_email.is_some()
1573            || info.admin_phone.is_some()
1574            || info.tech_name.is_some()
1575            || info.tech_organization.is_some()
1576            || info.tech_email.is_some()
1577            || info.tech_phone.is_some();
1578
1579        if has_any_contact {
1580            output.push(String::new());
1581            output.push("### Contacts".to_string());
1582            output.push(String::new());
1583            output.push("| Role | Name | Organization | Email | Phone |".to_string());
1584            output.push("| --- | --- | --- | --- | --- |".to_string());
1585
1586            let has_registrant = info.registrant_email.is_some()
1587                || info.registrant_phone.is_some()
1588                || info.registrant_address.is_some()
1589                || info.registrant_country.is_some();
1590            if has_registrant {
1591                output.push(format!(
1592                    "| Registrant | - | - | {} | {} |",
1593                    info.registrant_email.as_deref().unwrap_or("-"),
1594                    info.registrant_phone.as_deref().unwrap_or("-"),
1595                ));
1596            }
1597
1598            let has_admin = info.admin_name.is_some()
1599                || info.admin_organization.is_some()
1600                || info.admin_email.is_some()
1601                || info.admin_phone.is_some();
1602            if has_admin {
1603                output.push(format!(
1604                    "| Admin | {} | {} | {} | {} |",
1605                    info.admin_name.as_deref().unwrap_or("-"),
1606                    info.admin_organization.as_deref().unwrap_or("-"),
1607                    info.admin_email.as_deref().unwrap_or("-"),
1608                    info.admin_phone.as_deref().unwrap_or("-"),
1609                ));
1610            }
1611
1612            let has_tech = info.tech_name.is_some()
1613                || info.tech_organization.is_some()
1614                || info.tech_email.is_some()
1615                || info.tech_phone.is_some();
1616            if has_tech {
1617                output.push(format!(
1618                    "| Tech | {} | {} | {} | {} |",
1619                    info.tech_name.as_deref().unwrap_or("-"),
1620                    info.tech_organization.as_deref().unwrap_or("-"),
1621                    info.tech_email.as_deref().unwrap_or("-"),
1622                    info.tech_phone.as_deref().unwrap_or("-"),
1623                ));
1624            }
1625        }
1626
1627        // Protocol Metadata
1628        let has_metadata = info.whois_server.is_some() || info.rdap_url.is_some();
1629        if has_metadata {
1630            output.push(String::new());
1631            output.push("### Protocol Metadata".to_string());
1632            output.push(String::new());
1633            if let Some(ref whois_server) = info.whois_server {
1634                output.push(format!("- **WHOIS Server**: `{}`", whois_server));
1635            }
1636            if let Some(ref rdap_url) = info.rdap_url {
1637                output.push(format!("- **RDAP URL**: `{}`", rdap_url));
1638            }
1639        }
1640
1641        output.join("\n")
1642    }
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647    use super::*;
1648    use crate::dns::RecordType;
1649    use crate::status::StatusResponse;
1650
1651    #[test]
1652    fn test_markdown_format_status() {
1653        let response = StatusResponse::new("example.com".to_string());
1654        let formatter = MarkdownFormatter::new();
1655        let output = formatter.format_status(&response);
1656        assert!(output.contains("## Status: example.com"));
1657        assert!(output.contains("### SSL Certificate"));
1658        assert!(output.contains("### DNS Resolution"));
1659    }
1660
1661    #[test]
1662    fn test_markdown_format_dns_records() {
1663        let records = vec![DnsRecord {
1664            name: "example.com".to_string(),
1665            record_type: RecordType::A,
1666            ttl: 300,
1667            data: crate::dns::RecordData::A {
1668                address: "93.184.216.34".to_string(),
1669            },
1670        }];
1671        let formatter = MarkdownFormatter::new();
1672        let output = formatter.format_dns(&records);
1673        assert!(output.contains("## DNS A Records: example.com"));
1674        assert!(output.contains("| Name | TTL | Type | Data |"));
1675        assert!(output.contains("93.184.216.34"));
1676        assert!(
1677            output.contains("DNSSEC-validated"),
1678            "DNS output must disclose DNSSEC is not validated"
1679        );
1680    }
1681
1682    #[test]
1683    fn test_markdown_format_dns_empty() {
1684        let formatter = MarkdownFormatter::new();
1685        let output = formatter.format_dns(&[]);
1686        assert!(output.contains("No records found"));
1687        assert!(output.contains("DNSSEC-validated"));
1688    }
1689
1690    #[test]
1691    fn test_markdown_format_availability() {
1692        let result = crate::availability::AvailabilityResult {
1693            domain: "test.com".to_string(),
1694            available: true,
1695            confidence: "high".to_string(),
1696            method: "RDAP+WHOIS".to_string(),
1697            details: Some("Domain not found".to_string()),
1698        };
1699        let formatter = MarkdownFormatter::new();
1700        let output = formatter.format_availability(&result);
1701        assert!(output.contains("## Availability: test.com"));
1702        assert!(output.contains("**AVAILABLE**"));
1703        assert!(output.contains("high"));
1704    }
1705}