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