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