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