Skip to main content

seer_core/output/
markdown.rs

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