1use chrono::TimeDelta;
2use once_cell::sync::Lazy;
3use regex::Regex;
4
5use super::OutputFormatter;
6
7pub(super) use super::grouping::render_grouped;
9pub(super) use crate::caa::{CaaPolicy, IssuerCaaMatch};
10pub(super) use crate::colors::CatppuccinExt;
11pub(super) use crate::dns::{DnsRecord, FollowIteration, FollowResult, PropagationResult};
12pub(super) use crate::lookup::LookupResult;
13pub(super) use crate::rdap::RdapResponse;
14pub(super) use crate::status::StatusResponse;
15pub(super) use crate::whois::WhoisResponse;
16pub(super) use colored::Colorize;
17
18mod diff;
19mod dns;
20mod domain_info;
21mod lookup;
22mod propagation;
23mod rdap;
24mod status;
25mod whois;
26
27static ANSI_ESCAPE_RE: Lazy<Regex> = Lazy::new(|| {
30 Regex::new(r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[A-Z@-_]")
31 .expect("Invalid ANSI escape regex")
32});
33
34pub(super) fn sanitize_display(s: &str) -> String {
35 ANSI_ESCAPE_RE.replace_all(s, "").to_string()
36}
37
38pub(super) fn format_duration(duration: TimeDelta) -> String {
39 let total_secs = duration.num_seconds();
40 if total_secs < 60 {
41 format!("{}s", total_secs)
42 } else if total_secs < 3600 {
43 let mins = total_secs / 60;
44 let secs = total_secs % 60;
45 format!("{}m {}s", mins, secs)
46 } else {
47 let hours = total_secs / 3600;
48 let mins = (total_secs % 3600) / 60;
49 format!("{}h {}m", hours, mins)
50 }
51}
52
53pub struct HumanFormatter {
54 use_colors: bool,
55}
56
57impl Default for HumanFormatter {
58 fn default() -> Self {
59 Self::new()
60 }
61}
62
63impl HumanFormatter {
64 pub fn new() -> Self {
65 Self { use_colors: true }
66 }
67
68 pub fn without_colors(mut self) -> Self {
69 self.use_colors = false;
70 self
71 }
72
73 fn label(&self, text: &str) -> String {
74 if self.use_colors {
75 text.sky().bold().to_string()
76 } else {
77 text.to_string()
78 }
79 }
80
81 fn value(&self, text: &str) -> String {
82 if self.use_colors {
83 text.ctp_white().to_string()
84 } else {
85 text.to_string()
86 }
87 }
88
89 fn success(&self, text: &str) -> String {
90 if self.use_colors {
91 text.ctp_green().bold().to_string()
92 } else {
93 text.to_string()
94 }
95 }
96
97 fn warning(&self, text: &str) -> String {
98 if self.use_colors {
99 text.ctp_yellow().bold().to_string()
100 } else {
101 text.to_string()
102 }
103 }
104
105 fn error(&self, text: &str) -> String {
106 if self.use_colors {
107 text.ctp_red().bold().to_string()
108 } else {
109 text.to_string()
110 }
111 }
112
113 fn dim(&self, text: &str) -> String {
114 if self.use_colors {
115 text.overlay1().to_string()
116 } else {
117 text.to_string()
118 }
119 }
120
121 fn header(&self, text: &str) -> String {
122 let width = text.chars().count();
127 if self.use_colors {
128 format!(
129 "\n{}\n{}",
130 text.lavender().bold(),
131 "─".repeat(width).subtext0()
132 )
133 } else {
134 format!("\n{}\n{}", text, "-".repeat(width))
135 }
136 }
137
138 fn render_caa_block(&self, caa: &CaaPolicy, indent: &str) -> Vec<String> {
142 let mut out = Vec::new();
143 out.push(format!("\n{}{}:", indent, self.label("CAA Policy")));
144
145 if !caa.has_policy {
146 out.push(format!(
147 "{} {}",
148 indent,
149 self.value("No CAA records (any CA may issue)")
150 ));
151 } else {
152 if let Some(ref eff) = caa.effective_domain {
153 out.push(format!(
154 "{} {}: {}",
155 indent,
156 self.label("Found at"),
157 self.value(&sanitize_display(eff))
158 ));
159 }
160 for r in &caa.records {
161 out.push(format!(
162 "{} {} {} \"{}\"",
163 indent,
164 self.value(&r.flags.to_string()),
165 self.label(&r.tag),
166 sanitize_display(&r.value)
167 ));
168 }
169 }
170
171 if let Some(m) = caa.issuer_match {
172 let rendered = match m {
173 IssuerCaaMatch::NoPolicy => self.value("no policy — any CA permitted"),
174 IssuerCaaMatch::Permitted => self.success("issuer permitted by current CAA policy"),
175 IssuerCaaMatch::Mismatch => self
176 .warning("issuer not in current CAA policy (informational — see note below)"),
177 IssuerCaaMatch::Indeterminate => {
178 self.warning("CAA present but no issue/issuewild tags")
179 }
180 };
181 out.push(format!(
182 "{} {}: {}",
183 indent,
184 self.label("Issuer vs CAA"),
185 rendered
186 ));
187 }
188
189 out
192 }
193
194 fn push_caa_note_footer(&self, out: &mut Vec<String>, caa: &CaaPolicy) {
199 out.push(String::new());
200 out.push(format!("note: {}", caa.note));
201 }
202
203 fn format_expiry_status(&self, expiry_str: &str, days_until: i64) -> String {
211 if days_until < 0 {
212 self.error(&format!(
213 "{} (expired {} days ago)",
214 expiry_str, -days_until
215 ))
216 } else if days_until < 30 {
217 self.error(&format!("{} (expires in {} days!)", expiry_str, days_until))
218 } else if days_until < 90 {
219 self.warning(&format!("{} (expires in {} days)", expiry_str, days_until))
220 } else {
221 self.success(&format!("{} (expires in {} days)", expiry_str, days_until))
222 }
223 }
224}
225
226impl OutputFormatter for HumanFormatter {
230 fn format_whois(&self, response: &WhoisResponse) -> String {
231 self.format_whois(response)
232 }
233 fn format_rdap(&self, response: &RdapResponse) -> String {
234 self.format_rdap(response)
235 }
236 fn format_dns(&self, records: &[DnsRecord]) -> String {
237 self.format_dns(records)
238 }
239 fn format_propagation(&self, result: &PropagationResult) -> String {
240 self.format_propagation(result)
241 }
242 fn format_lookup(&self, result: &LookupResult) -> String {
243 self.format_lookup(result)
244 }
245 fn format_status(&self, response: &StatusResponse) -> String {
246 self.format_status(response)
247 }
248 fn format_follow_iteration(&self, iteration: &FollowIteration) -> String {
249 self.format_follow_iteration(iteration)
250 }
251 fn format_follow(&self, result: &FollowResult) -> String {
252 self.format_follow(result)
253 }
254 fn format_availability(&self, result: &crate::availability::AvailabilityResult) -> String {
255 self.format_availability(result)
256 }
257 fn format_dnssec(&self, report: &crate::dns::DnssecReport) -> String {
258 self.format_dnssec(report)
259 }
260 fn format_tld(&self, info: &crate::tld::TldInfo) -> String {
261 self.format_tld(info)
262 }
263 fn format_dns_comparison(&self, comparison: &crate::dns::DnsComparison) -> String {
264 self.format_dns_comparison(comparison)
265 }
266 fn format_subdomains(&self, result: &crate::subdomains::SubdomainResult) -> String {
267 self.format_subdomains(result)
268 }
269 fn format_diff(&self, diff: &crate::diff::DomainDiff) -> String {
270 self.format_diff(diff)
271 }
272 fn format_ssl(&self, report: &crate::ssl::SslReport) -> String {
273 self.format_ssl(report)
274 }
275 fn format_watch(&self, report: &crate::watchlist::WatchReport) -> String {
276 self.format_watch(report)
277 }
278 fn format_domain_info(&self, info: &crate::domain_info::DomainInfo) -> String {
279 self.format_domain_info(info)
280 }
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286
287 fn formatter() -> HumanFormatter {
288 HumanFormatter::new().without_colors()
289 }
290
291 #[test]
292 fn expired_shows_days_ago() {
293 let f = formatter();
294 let out = f.format_expiry_status("2024-01-01", -3);
295 assert!(out.contains("expired 3 days ago"), "got: {}", out);
296 assert!(!out.contains("-3"), "got: {}", out);
297 }
298
299 #[test]
300 fn expiring_soon_shows_expires_in() {
301 let f = formatter();
302 let out = f.format_expiry_status("2026-05-01", 15);
303 assert!(out.contains("expires in 15 days"), "got: {}", out);
304 assert!(!out.contains("days ago"), "got: {}", out);
305 }
306
307 #[test]
308 fn warning_window_uses_expires_in() {
309 let f = formatter();
310 let out = f.format_expiry_status("2026-07-01", 60);
311 assert!(out.contains("expires in 60 days"), "got: {}", out);
312 assert!(!out.contains("!"), "got: {}", out);
313 }
314
315 #[test]
316 fn healthy_expiry_uses_expires_in() {
317 let f = formatter();
318 let out = f.format_expiry_status("2027-01-01", 300);
319 assert!(out.contains("expires in 300 days"), "got: {}", out);
320 assert!(!out.contains("!"), "got: {}", out);
321 }
322
323 #[test]
324 fn expired_one_day_is_pluralized_simply() {
325 let f = formatter();
327 let out = f.format_expiry_status("2024-01-01", -1);
328 assert!(out.contains("expired 1 days ago"), "got: {}", out);
329 }
330
331 #[test]
332 fn boundary_30_days_is_warning_not_error() {
333 let f = formatter();
334 let out = f.format_expiry_status("2026-05-15", 30);
336 assert!(out.contains("expires in 30 days"), "got: {}", out);
337 assert!(!out.contains("!"), "got: {}", out);
338 }
339}