1mod grouping;
2mod human;
3mod json;
4mod markdown;
5
6pub use human::HumanFormatter;
7pub use json::JsonFormatter;
8pub use markdown::MarkdownFormatter;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13fn days_until(when: DateTime<Utc>) -> i64 {
17 crate::dates::days_until(when, Utc::now())
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum OutputFormat {
23 #[default]
24 Human,
25 Json,
26 Yaml,
27 Markdown,
28}
29
30impl std::str::FromStr for OutputFormat {
31 type Err = String;
32
33 fn from_str(s: &str) -> Result<Self, Self::Err> {
34 match s.to_lowercase().as_str() {
35 "human" | "text" | "pretty" => Ok(OutputFormat::Human),
36 "json" => Ok(OutputFormat::Json),
37 "yaml" | "yml" => Ok(OutputFormat::Yaml),
38 "markdown" | "md" => Ok(OutputFormat::Markdown),
39 _ => Err(format!(
40 "Unknown output format: {}. Use: human, json, yaml, markdown",
41 s
42 )),
43 }
44 }
45}
46
47pub trait OutputFormatter {
48 fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String;
49 fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String;
50 fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String;
51 fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String;
52 fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String;
53 fn format_status(&self, response: &crate::status::StatusResponse) -> String;
54 fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String;
55 fn format_follow(&self, result: &crate::dns::FollowResult) -> String;
56 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String;
57 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String;
58 fn format_delegation(&self, report: &crate::dns::DelegationReport) -> String;
59 fn format_tld(&self, info: &crate::tld::TldInfo) -> String;
60 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String;
61 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String;
62 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String;
63 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String;
64 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String;
65 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String;
66 fn format_drift(&self, report: &crate::drift::DriftReport) -> String;
67 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String;
68 fn format_headers(&self, report: &crate::headers::HeaderReport) -> String;
69 fn format_takeover(&self, report: &crate::takeover::TakeoverReport) -> String;
70 fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String;
71 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String;
72 fn format_subdomain_classification(
73 &self,
74 result: &crate::subdomains::SubdomainClassification,
75 ) -> String;
76 fn format_subdomain_baseline_diff(
77 &self,
78 report: &crate::subdomains::SubdomainBaselineDiff,
79 ) -> String;
80}
81
82pub struct YamlFormatter;
84
85impl YamlFormatter {
86 pub fn new() -> Self {
87 Self
88 }
89
90 pub fn to_yaml_value<T: Serialize + ?Sized>(&self, value: &T) -> String {
92 match serde_json::to_value(value) {
94 Ok(v) => format_as_yaml(&v, 0),
95 Err(e) => format!("error: {}", e),
96 }
97 }
98}
99
100impl Default for YamlFormatter {
101 fn default() -> Self {
102 Self::new()
103 }
104}
105
106impl OutputFormatter for YamlFormatter {
107 fn format_whois(&self, response: &crate::whois::WhoisResponse) -> String {
108 self.to_yaml_value(response)
109 }
110 fn format_rdap(&self, response: &crate::rdap::RdapResponse) -> String {
111 self.to_yaml_value(response)
112 }
113 fn format_dns(&self, records: &[crate::dns::DnsRecord]) -> String {
114 self.to_yaml_value(records)
115 }
116 fn format_propagation(&self, result: &crate::dns::PropagationResult) -> String {
117 self.to_yaml_value(result)
118 }
119 fn format_lookup(&self, result: &crate::lookup::LookupResult) -> String {
120 self.to_yaml_value(result)
121 }
122 fn format_status(&self, response: &crate::status::StatusResponse) -> String {
123 self.to_yaml_value(response)
124 }
125 fn format_follow_iteration(&self, iteration: &crate::dns::FollowIteration) -> String {
126 self.to_yaml_value(iteration)
127 }
128 fn format_follow(&self, result: &crate::dns::FollowResult) -> String {
129 self.to_yaml_value(result)
130 }
131 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
132 self.to_yaml_value(result)
133 }
134 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
135 self.to_yaml_value(report)
136 }
137 fn format_delegation(&self, report: &crate::dns::DelegationReport) -> String {
138 self.to_yaml_value(report)
139 }
140 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
141 self.to_yaml_value(info)
142 }
143 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
144 self.to_yaml_value(comparison)
145 }
146 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
147 self.to_yaml_value(result)
148 }
149 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
150 self.to_yaml_value(diff)
151 }
152 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
153 self.to_yaml_value(report)
154 }
155 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
156 self.to_yaml_value(report)
157 }
158 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
159 self.to_yaml_value(info)
160 }
161 fn format_drift(&self, report: &crate::drift::DriftReport) -> String {
162 self.to_yaml_value(report)
163 }
164 fn format_posture(&self, posture: &crate::posture::EmailPosture) -> String {
165 self.to_yaml_value(posture)
166 }
167 fn format_headers(&self, report: &crate::headers::HeaderReport) -> String {
168 self.to_yaml_value(report)
169 }
170 fn format_takeover(&self, report: &crate::takeover::TakeoverReport) -> String {
171 self.to_yaml_value(report)
172 }
173 fn format_caa(&self, policy: &crate::caa::CaaPolicy) -> String {
174 self.to_yaml_value(policy)
175 }
176 fn format_confusables(&self, report: &crate::confusables::ConfusableReport) -> String {
177 self.to_yaml_value(report)
178 }
179 fn format_subdomain_classification(
180 &self,
181 result: &crate::subdomains::SubdomainClassification,
182 ) -> String {
183 self.to_yaml_value(result)
184 }
185 fn format_subdomain_baseline_diff(
186 &self,
187 report: &crate::subdomains::SubdomainBaselineDiff,
188 ) -> String {
189 self.to_yaml_value(report)
190 }
191}
192
193fn yaml_needs_quoting(s: &str) -> bool {
212 if s.is_empty() || s != s.trim() {
213 return true;
214 }
215 if s.chars()
216 .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}'))
217 {
218 return true;
219 }
220 if matches!(
221 s.to_ascii_lowercase().as_str(),
222 "null" | "~" | "true" | "false" | "yes" | "no" | "y" | "n" | "on" | "off" | "=" | "<<"
223 ) {
224 return true;
225 }
226 if let Some(first) = s.chars().next() {
227 if "-?:,[]{}#&*!|>'\"%@`".contains(first) {
229 return true;
230 }
231 if first.is_ascii_digit() || first == '+' || first == '.' {
233 return true;
234 }
235 }
236 s.contains(": ") || s.ends_with(':') || s.contains(" #")
237}
238
239fn yaml_quote(s: &str) -> String {
243 let mut out = String::with_capacity(s.len() + 2);
244 out.push('"');
245 for c in s.chars() {
246 match c {
247 '"' => out.push_str("\\\""),
248 '\\' => out.push_str("\\\\"),
249 '\n' => out.push_str("\\n"),
250 '\t' => out.push_str("\\t"),
251 '\r' => out.push_str("\\r"),
252 '\0' => out.push_str("\\0"),
253 '\u{0085}' => out.push_str("\\N"),
256 '\u{2028}' => out.push_str("\\L"),
257 '\u{2029}' => out.push_str("\\P"),
258 c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
260 c => out.push(c),
261 }
262 }
263 out.push('"');
264 out
265}
266
267fn yaml_scalar(s: &str) -> String {
269 if yaml_needs_quoting(s) {
270 yaml_quote(s)
271 } else {
272 s.to_string()
273 }
274}
275
276fn format_as_yaml(value: &serde_json::Value, indent: usize) -> String {
278 let prefix = " ".repeat(indent);
279 match value {
280 serde_json::Value::Null => "null".to_string(),
281 serde_json::Value::Bool(b) => b.to_string(),
282 serde_json::Value::Number(n) => n.to_string(),
283 serde_json::Value::String(s) => yaml_scalar(s),
284 serde_json::Value::Array(arr) => {
285 if arr.is_empty() {
286 return "[]".to_string();
287 }
288 let mut out = String::new();
289 for item in arr {
290 out.push('\n');
291 out.push_str(&prefix);
292 out.push_str("- ");
293 let formatted = format_as_yaml(item, indent + 1);
294 out.push_str(&formatted);
295 }
296 out
297 }
298 serde_json::Value::Object(map) => {
299 if map.is_empty() {
300 return "{}".to_string();
301 }
302 let mut out = String::new();
303 let mut first = indent == 0;
304 for (key, val) in map {
305 if !first {
306 out.push('\n');
307 }
308 first = false;
309 out.push_str(&prefix);
310 out.push_str(&yaml_scalar(key));
313 out.push_str(": ");
314 match val {
315 serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
316 out.push_str(&format_as_yaml(val, indent + 1));
317 }
318 _ => {
319 out.push_str(&format_as_yaml(val, indent));
320 }
321 }
322 }
323 out
324 }
325 }
326}
327
328pub fn get_formatter(format: OutputFormat) -> Box<dyn OutputFormatter> {
329 match format {
330 OutputFormat::Human => Box::new(HumanFormatter::new()),
331 OutputFormat::Json => Box::new(JsonFormatter::new()),
332 OutputFormat::Yaml => Box::new(YamlFormatter::new()),
333 OutputFormat::Markdown => Box::new(MarkdownFormatter::new()),
334 }
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 #[test]
342 fn test_output_format_from_str() {
343 assert_eq!(
344 "human".parse::<OutputFormat>().unwrap(),
345 OutputFormat::Human
346 );
347 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
348 assert_eq!("yaml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
349 assert_eq!("yml".parse::<OutputFormat>().unwrap(), OutputFormat::Yaml);
350 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Human);
351 assert_eq!(
352 "pretty".parse::<OutputFormat>().unwrap(),
353 OutputFormat::Human
354 );
355 assert!("invalid".parse::<OutputFormat>().is_err());
356 }
357
358 #[test]
359 fn test_output_format_default() {
360 assert_eq!(OutputFormat::default(), OutputFormat::Human);
361 }
362
363 #[test]
364 fn test_get_formatter_returns_correct_type() {
365 let _ = get_formatter(OutputFormat::Human);
367 let _ = get_formatter(OutputFormat::Json);
368 let _ = get_formatter(OutputFormat::Yaml);
369 }
370
371 #[test]
372 fn test_yaml_formatter_basic() {
373 let formatter = YamlFormatter::new();
374 let status = crate::status::StatusResponse::new("example.com".to_string());
375 let output = formatter.format_status(&status);
376 assert!(output.contains("example.com"));
377 assert!(output.contains("domain"));
378 }
379
380 #[test]
381 fn test_format_as_yaml_primitives() {
382 assert_eq!(format_as_yaml(&serde_json::json!(null), 0), "null");
383 assert_eq!(format_as_yaml(&serde_json::json!(true), 0), "true");
384 assert_eq!(format_as_yaml(&serde_json::json!(42), 0), "42");
385 assert_eq!(format_as_yaml(&serde_json::json!("hello"), 0), "hello");
386 }
387
388 #[test]
389 fn test_format_as_yaml_array() {
390 let output = format_as_yaml(&serde_json::json!([1, 2, 3]), 0);
391 assert!(output.contains("- 1"));
392 assert!(output.contains("- 2"));
393 assert!(output.contains("- 3"));
394 }
395
396 #[test]
397 fn test_format_as_yaml_empty_collections() {
398 assert_eq!(format_as_yaml(&serde_json::json!([]), 0), "[]");
399 assert_eq!(format_as_yaml(&serde_json::json!({}), 0), "{}");
400 }
401
402 #[test]
405 fn yaml_scalar_quoting_hardened() {
406 use serde_json::json;
407 let y = |s: &str| format_as_yaml(&json!(s), 0);
408
409 assert_eq!(y("No"), "\"No\"");
411 assert_eq!(y("true"), "\"true\"");
412 assert_eq!(y("null"), "\"null\"");
413 assert_eq!(y("~"), "\"~\"");
414
415 assert_eq!(y(""), "\"\"");
417 assert_eq!(y(" leading"), "\" leading\"");
418 assert_eq!(y("trailing "), "\"trailing \"");
419
420 for s in [
422 "- dash", "@at", "*star", "[brk", "{brace", "#hash", "!bang", "&", "|pipe", ">gt",
423 "%pct", "`tick", "?q", ",comma",
424 ] {
425 assert_eq!(
426 y(s),
427 format!("\"{s}\""),
428 "must quote leading-indicator {s:?}"
429 );
430 }
431
432 assert_eq!(y("key: value"), "\"key: value\"");
434
435 assert_eq!(y("a\tb"), "\"a\\tb\"");
437 assert_eq!(y("a\rb"), "\"a\\rb\"");
438 assert_eq!(y("x\x1b[31m"), "\"x\\x1b[31m\"");
439
440 assert_eq!(y("plain-value"), "plain-value");
442 assert_eq!(y("has internal spaces"), "has internal spaces");
443 assert_eq!(y("ns1.example.com"), "ns1.example.com");
444 }
445
446 #[test]
447 fn yaml_quotes_strings_a_resolver_would_read_as_non_strings() {
448 use serde_json::json;
449 let y = |s: &str| format_as_yaml(&json!(s), 0);
450
451 for s in [
452 "+1.5555550100", "292", "0123", "1:20", "2024-01-15", "2024-01-15T04:00:00Z", ".inf",
459 ".NaN",
460 "=", "<<", "y", "N",
464 ] {
465 assert_eq!(y(s), format!("\"{s}\""), "must quote {s:?}");
466 }
467
468 assert_eq!(y("ns1.example.com"), "ns1.example.com");
470 assert_eq!(y("yes-but-longer"), "yes-but-longer");
471 assert_eq!(y("a=b"), "a=b");
472 assert_eq!(format_as_yaml(&json!(292), 0), "292");
473 assert_eq!(format_as_yaml(&json!(1.5), 0), "1.5");
474 }
475
476 #[test]
477 fn yaml_escapes_unicode_line_breaks() {
478 use serde_json::json;
479 let y = |s: &str| format_as_yaml(&json!(s), 0);
484 assert_eq!(y("a\u{2028}b"), "\"a\\Lb\"");
485 assert_eq!(y("a\u{2029}b"), "\"a\\Pb\"");
486 assert_eq!(y("a\u{0085}b"), "\"a\\Nb\"");
487
488 let doc = format_as_yaml(&json!({ "k\u{2028}ey": "v" }), 0);
489 assert_eq!(doc, "\"k\\Ley\": v");
490 }
491
492 #[test]
493 fn yaml_object_keys_with_significant_chars_are_quoted() {
494 let doc = format_as_yaml(&serde_json::json!({ "a\nb": "v" }), 0);
497 assert!(
498 doc.contains("\"a\\nb\""),
499 "malicious key must be quoted/escaped: {doc:?}"
500 );
501 let doc = format_as_yaml(&serde_json::json!({ "domain": "x" }), 0);
503 assert!(
504 doc.starts_with("domain:"),
505 "plain key must not be quoted: {doc:?}"
506 );
507 }
508}