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