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
9pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(ref title) = response.title {
849 output.push(format!("- **Site Title**: {}", title));
850 }
851
852 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 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 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 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 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 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 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 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 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 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 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 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 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 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}