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