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