1mod grouping;
2mod human;
3mod json;
4mod markdown;
5
6pub use human::HumanFormatter;
7pub use json::JsonFormatter;
8pub use markdown::MarkdownFormatter;
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
13#[serde(rename_all = "lowercase")]
14pub enum OutputFormat {
15 #[default]
16 Human,
17 Json,
18 Yaml,
19 Markdown,
20}
21
22impl std::str::FromStr for OutputFormat {
23 type Err = String;
24
25 fn from_str(s: &str) -> Result<Self, Self::Err> {
26 match s.to_lowercase().as_str() {
27 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
28 "json" => Ok(OutputFormat::Json),
29 "yaml" | "yml" => Ok(OutputFormat::Yaml),
30 "markdown" | "md" => Ok(OutputFormat::Markdown),
31 _ => Err(format!(
32 "Unknown output format: {}. Use: human, json, yaml, markdown",
33 s
34 )),
35 }
36 }
37}
38
39pub trait OutputFormatter {
40 fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String;
41 fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String;
42 fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String;
43 fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String;
44 fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String;
45 fn format_status(&self, response: &crate::status::StatusResponse) -> String;
46 fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String;
47 fn format_follow(&self, result: &crate::dns::FollowResult) -> String;
48 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String;
49 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String;
50 fn format_tld(&self, info: &crate::tld::TldInfo) -> String;
51 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String;
52 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String;
53 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String;
54 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String;
55 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String;
56 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String;
57 fn format_drift(&self, report: &crate::drift::DriftReport) -> String;
58 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String;
59 fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String;
60 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String;
61 fn format_subdomain_classification(
62 &self,
63 result: &crate::subdomains::SubdomainClassification,
64 ) -> String;
65 fn format_subdomain_baseline_diff(
66 &self,
67 report: &crate::subdomains::SubdomainBaselineDiff,
68 ) -> String;
69}
70
71pub struct YamlFormatter;
73
74impl YamlFormatter {
75 pub fn new() -> Self {
76 Self
77 }
78
79 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
81 match serde_json::to_value(value) {
83 Ok(v) => format_as_yaml(&v, 0),
84 Err(e) => format!("error: {}", e),
85 }
86 }
87}
88
89impl Default for YamlFormatter {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl OutputFormatter for YamlFormatter {
96 fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String {
97 self.to_yaml_value(response)
98 }
99 fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String {
100 self.to_yaml_value(response)
101 }
102 fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String {
103 self.to_yaml_value(records)
104 }
105 fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String {
106 self.to_yaml_value(result)
107 }
108 fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String {
109 self.to_yaml_value(result)
110 }
111 fn format_status(&self, response: &crate::status::StatusResponse) -> String {
112 self.to_yaml_value(response)
113 }
114 fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String {
115 self.to_yaml_value(iteration)
116 }
117 fn format_follow(&self, result: &crate::dns::FollowResult) -> String {
118 self.to_yaml_value(result)
119 }
120 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
121 self.to_yaml_value(result)
122 }
123 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
124 self.to_yaml_value(report)
125 }
126 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
127 self.to_yaml_value(info)
128 }
129 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
130 self.to_yaml_value(comparison)
131 }
132 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
133 self.to_yaml_value(result)
134 }
135 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
136 self.to_yaml_value(diff)
137 }
138 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
139 self.to_yaml_value(report)
140 }
141 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
142 self.to_yaml_value(report)
143 }
144 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
145 self.to_yaml_value(info)
146 }
147 fn format_drift(&self, report: &crate::drift::DriftReport) -> String {
148 self.to_yaml_value(report)
149 }
150 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String {
151 self.to_yaml_value(posture)
152 }
153 fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String {
154 self.to_yaml_value(policy)
155 }
156 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String {
157 self.to_yaml_value(report)
158 }
159 fn format_subdomain_classification(
160 &self,
161 result: &crate::subdomains::SubdomainClassification,
162 ) -> String {
163 self.to_yaml_value(result)
164 }
165 fn format_subdomain_baseline_diff(
166 &self,
167 report: &crate::subdomains::SubdomainBaselineDiff,
168 ) -> String {
169 self.to_yaml_value(report)
170 }
171}
172
173fn yaml_needs_quoting(s: &str) -> bool {
181 if s.is_empty() || s != s.trim() {
182 return true;
183 }
184 if s.chars().any(|c| c.is_control()) {
185 return true;
186 }
187 if matches!(
188 s.to_ascii_lowercase().as_str(),
189 "null" | "~" | "true" | "false" | "yes" | "no" | "on" | "off"
190 ) {
191 return true;
192 }
193 if let Some(first) = s.chars().next() {
195 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
196 return true;
197 }
198 }
199 s.contains(": ") || s.ends_with(':') || s.contains(" #")
200}
201
202fn yaml_quote(s: &str) -> String {
206 let mut out = String::with_capacity(s.len() + 2);
207 out.push('"');
208 for c in s.chars() {
209 match c {
210 '"' => out.push_str("\\\""),
211 '\\' => out.push_str("\\\\"),
212 '\n' => out.push_str("\\n"),
213 '\t' => out.push_str("\\t"),
214 '\r' => out.push_str("\\r"),
215 '\0' => out.push_str("\\0"),
216 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
218 c => out.push(c),
219 }
220 }
221 out.push('"');
222 out
223}
224
225fn yaml_scalar(s: &str) -> String {
227 if yaml_needs_quoting(s) {
228 yaml_quote(s)
229 } else {
230 s.to_string()
231 }
232}
233
234fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
236 let prefix = " ".repeat(indent);
237 match value {
238 serde_json::Value::Null => "null".to_string(),
239 serde_json::Value::Bool(b) => b.to_string(),
240 serde_json::Value::Number(n) => n.to_string(),
241 serde_json::Value::String(s) => yaml_scalar(s),
242 serde_json::Value::Array(arr) => {
243 if arr.is_empty() {
244 return "[]".to_string();
245 }
246 let mut out = String::new();
247 for item in arr {
248 out.push('\n');
249 out.push_str(&prefix);
250 out.push_str("- ");
251 let formatted = format_as_yaml(item, indent + 1);
252 out.push_str(&formatted);
253 }
254 out
255 }
256 serde_json::Value::Object(map) => {
257 if map.is_empty() {
258 return "{}".to_string();
259 }
260 let mut out = String::new();
261 let mut first = indent == 0;
262 for (key, val) in map {
263 if !first {
264 out.push('\n');
265 }
266 first = false;
267 out.push_str(&prefix);
268 out.push_str(&yaml_scalar(key));
271 out.push_str(": ");
272 match val {
273 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
274 out.push_str(&format_as_yaml(val, indent + 1));
275 }
276 _ => {
277 out.push_str(&format_as_yaml(val, indent));
278 }
279 }
280 }
281 out
282 }
283 }
284}
285
286pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
287 match format {
288 OutputFormat::Human => Box::new(HumanFormatter::new()),
289 OutputFormat::Json => Box::new(JsonFormatter::new()),
290 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
291 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298
299 #[test]
300 fn test_output_format_from_str() {
301 assert_eq!(
302 "human".parse::<OutputFormat>().unwrap(),
303 OutputFormat::Human
304 );
305 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
306 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
307 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
308 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
309 assert_eq!(
310 "pretty".parse::<OutputFormat>().unwrap(),
311 OutputFormat::Human
312 );
313 assert!("invalid".parse::<OutputFormat>().is_err());
314 }
315
316 #[test]
317 fn test_output_format_default() {
318 assert_eq!(OutputFormat::default(), OutputFormat::Human);
319 }
320
321 #[test]
322 fn test_get_formatter_returns_correct_type() {
323 let _ = get_formatter(OutputFormat::Human);
325 let _ = get_formatter(OutputFormat::Json);
326 let _ = get_formatter(OutputFormat::Yaml);
327 }
328
329 #[test]
330 fn test_yaml_formatter_basic() {
331 let formatter = YamlFormatter::new();
332 let status = crate::status::StatusResponse::new("example.com".to_string());
333 let output = formatter.format_status(&status);
334 assert!(output.contains("example.com"));
335 assert!(output.contains("domain"));
336 }
337
338 #[test]
339 fn test_format_as_yaml_primitives() {
340 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
341 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
342 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
343 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
344 }
345
346 #[test]
347 fn test_format_as_yaml_array() {
348 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
349 assert!(output.contains("- 1"));
350 assert!(output.contains("- 2"));
351 assert!(output.contains("- 3"));
352 }
353
354 #[test]
355 fn test_format_as_yaml_empty_collections() {
356 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
357 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
358 }
359
360 #[test]
363 fn yaml_scalar_quoting_hardened() {
364 use serde_json::json;
365 let y = |s: &str| format_as_yaml(&json!(s), 0);
366
367 assert_eq!(y("No"), "\"No\"");
369 assert_eq!(y("true"), "\"true\"");
370 assert_eq!(y("null"), "\"null\"");
371 assert_eq!(y("~"), "\"~\"");
372
373 assert_eq!(y(""), "\"\"");
375 assert_eq!(y(" leading"), "\" leading\"");
376 assert_eq!(y("trailing "), "\"trailing \"");
377
378 for s in [
380 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
381 "%pct", "`tick", "?q", ",comma",
382 ] {
383 assert_eq!(
384 y(s),
385 format!("\"{s}\""),
386 "must quote leading-indicator {s:?}"
387 );
388 }
389
390 assert_eq!(y("key: value"), "\"key: value\"");
392
393 assert_eq!(y("a\tb"), "\"a\\tb\"");
395 assert_eq!(y("a\rb"), "\"a\\rb\"");
396 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
397
398 assert_eq!(y("plain-value"), "plain-value");
400 assert_eq!(y("has internal spaces"), "has internal spaces");
401 assert_eq!(y("ns1.example.com"), "ns1.example.com");
402 }
403
404 #[test]
405 fn yaml_object_keys_with_significant_chars_are_quoted() {
406 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
409 assert!(
410 doc.contains("\"a\\nb\""),
411 "malicious key must be quoted/escaped: {doc:?}"
412 );
413 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
415 assert!(
416 doc.starts_with("domain:"),
417 "plain key must not be quoted: {doc:?}"
418 );
419 }
420}