1use std::fmt::Write as _;
8
9use crate::{CheckResult, EvidenceData, FileRef, Report, Severity, Verdict};
10
11pub fn to_terminal(report: &Report) -> String {
26 render(report, false)
27}
28
29pub fn to_terminal_color(report: &Report) -> String {
46 render(report, true)
47}
48
49fn render(report: &Report, color: bool) -> String {
50 let mut out = String::with_capacity(256);
51 let _ = write_header(&mut out, report, color);
52 let _ = write_summary(&mut out, report, color);
53 out.push('\n');
54 for check in &report.checks {
55 let _ = write_check(&mut out, check, color);
56 }
57 out
58}
59
60fn write_header(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
61 let bold = if color { "\x1b[1m" } else { "" };
62 let reset = if color { "\x1b[0m" } else { "" };
63 writeln!(
64 out,
65 "{}=== dev-report :: {} {} ==={}",
66 bold, report.subject, report.subject_version, reset
67 )?;
68 if let Some(p) = &report.producer {
69 writeln!(out, "producer: {}", p)?;
70 }
71 writeln!(out, "schema: v{}", report.schema_version)?;
72 Ok(())
73}
74
75fn write_summary(out: &mut String, report: &Report, color: bool) -> std::fmt::Result {
76 let (mut p, mut f, mut w, mut s) = (0usize, 0usize, 0usize, 0usize);
77 for c in &report.checks {
78 match c.verdict {
79 Verdict::Pass => p += 1,
80 Verdict::Fail => f += 1,
81 Verdict::Warn => w += 1,
82 Verdict::Skip => s += 1,
83 }
84 }
85 let overall = report.overall_verdict();
86 let label = verdict_label(overall, color);
87 writeln!(
88 out,
89 "verdict: {} ({} checks: {} fail, {} warn, {} pass, {} skip)",
90 label,
91 report.checks.len(),
92 f,
93 w,
94 p,
95 s
96 )?;
97 if let Some(end) = report.finished_at {
98 let dur_ms = (end - report.started_at).num_milliseconds();
99 writeln!(
100 out,
101 "duration: {} -> {} ({}ms)",
102 report.started_at.format("%Y-%m-%d %H:%M:%S"),
103 end.format("%H:%M:%S"),
104 dur_ms
105 )?;
106 } else {
107 writeln!(
108 out,
109 "started: {}",
110 report.started_at.format("%Y-%m-%d %H:%M:%S")
111 )?;
112 }
113 Ok(())
114}
115
116fn write_check(out: &mut String, c: &CheckResult, color: bool) -> std::fmt::Result {
117 let badge = check_badge(c, color);
118 let dim = if color { "\x1b[2m" } else { "" };
119 let reset = if color { "\x1b[0m" } else { "" };
120 let dur = c
121 .duration_ms
122 .map(|ms| format!(" {dim}{ms}ms{reset}"))
123 .unwrap_or_default();
124 writeln!(out, "{} {}{}", badge, c.name, dur)?;
125
126 if !c.tags.is_empty() {
127 writeln!(out, " tags: {}", c.tags.join(", "))?;
128 }
129 if let Some(detail) = &c.detail {
130 writeln!(out, " detail: {}", detail)?;
131 }
132 if !c.evidence.is_empty() {
133 writeln!(out, " evidence:")?;
134 for e in &c.evidence {
135 write_evidence(out, &e.label, &e.data)?;
136 }
137 }
138 Ok(())
139}
140
141fn write_evidence(out: &mut String, label: &str, data: &EvidenceData) -> std::fmt::Result {
142 match data {
143 EvidenceData::Numeric(n) => {
144 writeln!(out, " - {}: {}", label, n)
145 }
146 EvidenceData::Snippet(s) => {
147 writeln!(out, " - {}: {:?}", label, s)
148 }
149 EvidenceData::FileRef(f) => {
150 writeln!(out, " - {}: {}", label, file_ref_inline(f))
151 }
152 EvidenceData::KeyValue(map) => {
153 let pairs: Vec<String> = map.iter().map(|(k, v)| format!("{}: {}", k, v)).collect();
154 writeln!(out, " - {}: {{ {} }}", label, pairs.join(", "))
155 }
156 }
157}
158
159fn file_ref_inline(f: &FileRef) -> String {
160 match (f.line_start, f.line_end) {
161 (Some(s), Some(e)) if s == e => format!("{}:{}", f.path, s),
162 (Some(s), Some(e)) => format!("{}:{}-{}", f.path, s, e),
163 (Some(s), None) => format!("{}:{}", f.path, s),
164 _ => f.path.clone(),
165 }
166}
167
168fn verdict_label(v: Verdict, color: bool) -> String {
169 if !color {
170 return match v {
171 Verdict::Pass => "PASS",
172 Verdict::Fail => "FAIL",
173 Verdict::Warn => "WARN",
174 Verdict::Skip => "SKIP",
175 }
176 .to_string();
177 }
178 match v {
179 Verdict::Pass => "\x1b[32mPASS\x1b[0m".to_string(),
180 Verdict::Fail => "\x1b[31mFAIL\x1b[0m".to_string(),
181 Verdict::Warn => "\x1b[33mWARN\x1b[0m".to_string(),
182 Verdict::Skip => "\x1b[2mSKIP\x1b[0m".to_string(),
183 }
184}
185
186fn check_badge(c: &CheckResult, color: bool) -> String {
187 let sev = c
188 .severity
189 .map(|s| match s {
190 Severity::Info => "info",
191 Severity::Warning => "warning",
192 Severity::Error => "error",
193 Severity::Critical => "critical",
194 })
195 .map(|s| format!(" {}", s))
196 .unwrap_or_default();
197 let label = match c.verdict {
198 Verdict::Pass => "PASS",
199 Verdict::Fail => "FAIL",
200 Verdict::Warn => "WARN",
201 Verdict::Skip => "SKIP",
202 };
203 if !color {
204 return format!("[{}{}]", label, sev);
205 }
206 let (open, close) = match c.verdict {
207 Verdict::Pass => ("\x1b[32m", "\x1b[0m"),
208 Verdict::Fail => ("\x1b[31m", "\x1b[0m"),
209 Verdict::Warn => ("\x1b[33m", "\x1b[0m"),
210 Verdict::Skip => ("\x1b[2m", "\x1b[0m"),
211 };
212 format!("[{}{}{}{}]", open, label, sev, close)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::{Evidence, EvidenceKind};
219
220 fn sample_report() -> Report {
221 let mut r = Report::new("widget", "0.1.0").with_producer("dev-report-test");
222 r.push(CheckResult::pass("compile").with_duration_ms(7));
223 r.push(
224 CheckResult::warn("flaky", Severity::Warning)
225 .with_tag("bench")
226 .with_evidence(Evidence::numeric("mean_ns", 1234.5))
227 .with_evidence(Evidence::kv("env", [("CI", "true")])),
228 );
229 r.push(
230 CheckResult::fail("chaos::recover", Severity::Critical)
231 .with_tags(["chaos", "recovery"])
232 .with_detail("recovery did not restore final state")
233 .with_evidence(Evidence::file_ref_lines("site", "src/recover.rs", 10, 20)),
234 );
235 r.push(CheckResult::skip("not_applicable"));
236 r.finish();
237 r
238 }
239
240 #[test]
241 fn monochrome_render_contains_all_checks() {
242 let out = to_terminal(&sample_report());
243 assert!(out.contains("compile"));
244 assert!(out.contains("flaky"));
245 assert!(out.contains("chaos::recover"));
246 assert!(out.contains("not_applicable"));
247 assert!(out.contains("[PASS]"));
248 assert!(out.contains("[WARN warning]"));
249 assert!(out.contains("[FAIL critical]"));
250 assert!(out.contains("[SKIP]"));
251 }
252
253 #[test]
254 fn monochrome_render_has_no_ansi() {
255 let out = to_terminal(&sample_report());
256 assert!(!out.contains('\x1b'));
257 }
258
259 #[test]
260 fn color_render_has_ansi() {
261 let out = to_terminal_color(&sample_report());
262 assert!(out.contains('\x1b'));
263 assert!(out.contains("\x1b[31m")); assert!(out.contains("\x1b[32m")); assert!(out.contains("\x1b[33m")); }
267
268 #[test]
269 fn render_includes_evidence() {
270 let out = to_terminal(&sample_report());
271 assert!(out.contains("mean_ns"));
272 assert!(out.contains("1234.5"));
273 assert!(out.contains("CI: true"));
274 assert!(out.contains("src/recover.rs:10-20"));
275 }
276
277 #[test]
278 fn render_includes_tags_and_detail() {
279 let out = to_terminal(&sample_report());
280 assert!(out.contains("tags: chaos, recovery"));
281 assert!(out.contains("detail: recovery did not restore final state"));
282 }
283
284 #[test]
285 fn render_includes_summary_counts() {
286 let out = to_terminal(&sample_report());
287 assert!(out.contains("4 checks"));
288 assert!(out.contains("1 fail"));
289 assert!(out.contains("1 warn"));
290 assert!(out.contains("1 pass"));
291 assert!(out.contains("1 skip"));
292 }
293
294 #[test]
295 fn fits_under_80_columns_for_typical_report() {
296 let out = to_terminal(&sample_report());
297 for line in out.lines() {
298 assert!(
299 line.chars().count() <= 80,
300 "line exceeds 80 cols: {:?}",
301 line
302 );
303 }
304 }
305
306 #[test]
307 fn pure_function_same_input_same_output() {
308 let r = sample_report();
309 let a = to_terminal(&r);
310 let b = to_terminal(&r);
311 assert_eq!(a, b);
312 }
313
314 #[test]
315 fn empty_report_renders() {
316 let r = Report::new("nothing", "0.0.0");
317 let out = to_terminal(&r);
318 assert!(out.contains("nothing"));
319 assert!(out.contains("0 checks"));
320 }
321
322 #[test]
323 fn file_ref_inline_formats() {
324 let no_lines = file_ref_inline(&FileRef::new("a.rs"));
325 assert_eq!(no_lines, "a.rs");
326 let single = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 5));
327 assert_eq!(single, "a.rs:5");
328 let range = file_ref_inline(&FileRef::new("a.rs").with_line_range(5, 9));
329 assert_eq!(range, "a.rs:5-9");
330 }
331
332 #[test]
333 fn evidence_kind_dispatch_covers_all_variants() {
334 let r = sample_report();
335 let kinds: std::collections::HashSet<_> = r
336 .checks
337 .iter()
338 .flat_map(|c| &c.evidence)
339 .map(|e| e.kind())
340 .collect();
341 assert!(kinds.contains(&EvidenceKind::Numeric));
343 assert!(kinds.contains(&EvidenceKind::KeyValue));
344 assert!(kinds.contains(&EvidenceKind::FileRef));
345 }
346}