1use std::fmt::Write;
7
8use crate::coverage::{CoverageDelta, CoverageResult, FileCoverage};
9
10pub fn format_coverage_summary(result: &CoverageResult) -> String {
12 let mut out = String::with_capacity(2048);
13
14 write_header(&mut out, result);
15 write_file_table(&mut out, result);
16
17 if result.total_branches > 0 {
18 write_branch_summary(&mut out, result);
19 }
20
21 if result.uncovered_file_count() > 0 {
22 write_uncovered_files(&mut out, result);
23 }
24
25 out
26}
27
28fn write_header(out: &mut String, result: &CoverageResult) {
29 let _ = writeln!(out);
30 let _ = writeln!(out, " Coverage Summary");
31 let _ = writeln!(out, " ═══════════════════════════════════════");
32 let _ = writeln!(
33 out,
34 " Lines: {}/{} ({:.1}%)",
35 result.covered_lines, result.total_lines, result.percentage
36 );
37 if result.total_branches > 0 {
38 let _ = writeln!(
39 out,
40 " Branches: {}/{} ({:.1}%)",
41 result.covered_branches, result.total_branches, result.branch_percentage
42 );
43 }
44 let _ = writeln!(out, " Files: {}", result.files.len());
45 let _ = writeln!(out);
46}
47
48fn write_file_table(out: &mut String, result: &CoverageResult) {
49 if result.files.is_empty() {
50 return;
51 }
52
53 let max_name = result
55 .files
56 .iter()
57 .map(|f| f.path.to_string_lossy().len())
58 .max()
59 .unwrap_or(10)
60 .min(60);
61
62 let _ = writeln!(
63 out,
64 " {:<width$} {:>6} {:>6} {:>7}",
65 "File",
66 "Lines",
67 "Cover",
68 "Pct",
69 width = max_name
70 );
71 let _ = writeln!(
72 out,
73 " {:<width$} {:>6} {:>6} {:>7}",
74 "─".repeat(max_name),
75 "──────",
76 "──────",
77 "───────",
78 width = max_name
79 );
80
81 let mut sorted_files: Vec<&FileCoverage> = result.files.iter().collect();
82 sorted_files.sort_by(|a, b| {
83 a.percentage()
84 .partial_cmp(&b.percentage())
85 .unwrap_or(std::cmp::Ordering::Equal)
86 });
87
88 for file in &sorted_files {
89 let name = file.path.to_string_lossy();
90 let display_name = if name.len() > max_name {
91 format!("…{}", &name[name.len() - max_name + 1..])
92 } else {
93 name.to_string()
94 };
95
96 let bar = coverage_bar(file.percentage(), 7);
97 let _ = writeln!(
98 out,
99 " {:<width$} {:>6} {:>6} {} {:.1}%",
100 display_name,
101 file.total_lines,
102 file.covered_lines,
103 bar,
104 file.percentage(),
105 width = max_name
106 );
107 }
108 let _ = writeln!(out);
109}
110
111fn write_branch_summary(out: &mut String, result: &CoverageResult) {
112 let _ = writeln!(
113 out,
114 " Branch Coverage: {}/{} ({:.1}%)",
115 result.covered_branches, result.total_branches, result.branch_percentage
116 );
117 let _ = writeln!(out);
118}
119
120fn write_uncovered_files(out: &mut String, result: &CoverageResult) {
121 let uncovered: Vec<&FileCoverage> = result
122 .files
123 .iter()
124 .filter(|f| f.covered_lines == 0 && f.total_lines > 0)
125 .collect();
126
127 if uncovered.is_empty() {
128 return;
129 }
130
131 let _ = writeln!(out, " Uncovered Files ({}):", uncovered.len());
132 for file in &uncovered {
133 let _ = writeln!(
134 out,
135 " ⚠ {} ({} lines)",
136 file.path.display(),
137 file.total_lines
138 );
139 }
140 let _ = writeln!(out);
141}
142
143fn coverage_bar(percentage: f64, width: usize) -> String {
145 let filled = ((percentage / 100.0) * width as f64).round() as usize;
146 let filled = filled.min(width);
147 let empty = width - filled;
148
149 format!("│{}{}│", "█".repeat(filled), "░".repeat(empty))
150}
151
152pub fn format_threshold_check(result: &CoverageResult, threshold: f64) -> String {
154 let met = result.meets_threshold(threshold);
155 if met {
156 format!(
157 " ✅ Coverage {:.1}% meets threshold {:.1}%",
158 result.percentage, threshold
159 )
160 } else {
161 format!(
162 " ❌ Coverage {:.1}% is below threshold {:.1}% (need {:.1}% more)",
163 result.percentage,
164 threshold,
165 threshold - result.percentage
166 )
167 }
168}
169
170pub fn format_coverage_delta(delta: &CoverageDelta) -> String {
172 let mut out = String::with_capacity(512);
173
174 let _ = writeln!(out, " Coverage Change: {}", delta.format_delta());
175 let _ = writeln!(out);
176
177 if !delta.file_deltas.is_empty() {
178 let _ = writeln!(out, " Changed Files:");
179 let count = delta.file_deltas.len().min(10);
180 for fd in delta.file_deltas.iter().take(count) {
181 let arrow = if fd.delta > 0.0 { "↑" } else { "↓" };
182 let _ = writeln!(
183 out,
184 " {} {} {:.1}% → {:.1}% ({}{:.1}%)",
185 arrow,
186 fd.path.display(),
187 fd.old_percentage,
188 fd.new_percentage,
189 if fd.delta > 0.0 { "+" } else { "" },
190 fd.delta,
191 );
192 }
193 if delta.file_deltas.len() > count {
194 let _ = writeln!(
195 out,
196 " ... and {} more files",
197 delta.file_deltas.len() - count
198 );
199 }
200 }
201
202 out
203}
204
205pub fn format_coverage_json(result: &CoverageResult) -> String {
207 serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::coverage::FileCoverageDelta;
214 use std::collections::HashMap;
215 use std::path::PathBuf;
216
217 fn make_file(path: &str, total: usize, covered: usize) -> FileCoverage {
218 FileCoverage {
219 path: PathBuf::from(path),
220 total_lines: total,
221 covered_lines: covered,
222 uncovered_ranges: Vec::new(),
223 line_hits: HashMap::new(),
224 total_branches: 0,
225 covered_branches: 0,
226 }
227 }
228
229 fn make_result() -> CoverageResult {
230 CoverageResult::from_files(vec![
231 make_file("src/main.rs", 100, 80),
232 make_file("src/lib.rs", 200, 190),
233 make_file("src/util.rs", 50, 0),
234 ])
235 }
236
237 #[test]
238 fn summary_contains_header() {
239 let summary = format_coverage_summary(&make_result());
240 assert!(summary.contains("Coverage Summary"));
241 assert!(summary.contains("Lines:"));
242 }
243
244 #[test]
245 fn summary_contains_totals() {
246 let summary = format_coverage_summary(&make_result());
247 assert!(summary.contains("270")); assert!(summary.contains("350")); }
250
251 #[test]
252 fn summary_contains_files() {
253 let summary = format_coverage_summary(&make_result());
254 assert!(summary.contains("src/main.rs"));
255 assert!(summary.contains("src/lib.rs"));
256 assert!(summary.contains("src/util.rs"));
257 }
258
259 #[test]
260 fn summary_uncovered_files() {
261 let summary = format_coverage_summary(&make_result());
262 assert!(summary.contains("Uncovered Files"));
263 assert!(summary.contains("src/util.rs"));
264 }
265
266 #[test]
267 fn coverage_bar_full() {
268 let bar = coverage_bar(100.0, 5);
269 assert!(bar.contains("█████"));
270 }
271
272 #[test]
273 fn coverage_bar_empty() {
274 let bar = coverage_bar(0.0, 5);
275 assert!(bar.contains("░░░░░"));
276 }
277
278 #[test]
279 fn coverage_bar_half() {
280 let bar = coverage_bar(50.0, 4);
281 assert!(bar.contains("██"));
282 assert!(bar.contains("░░"));
283 }
284
285 #[test]
286 fn threshold_met() {
287 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
288 let msg = format_threshold_check(&result, 80.0);
289 assert!(msg.contains("✅"));
290 assert!(msg.contains("meets"));
291 }
292
293 #[test]
294 fn threshold_not_met() {
295 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
296 let msg = format_threshold_check(&result, 80.0);
297 assert!(msg.contains("❌"));
298 assert!(msg.contains("below"));
299 }
300
301 #[test]
302 fn delta_format() {
303 let delta = CoverageDelta {
304 line_delta: 5.0,
305 branch_delta: 0.0,
306 file_deltas: vec![FileCoverageDelta {
307 path: PathBuf::from("a.rs"),
308 old_percentage: 70.0,
309 new_percentage: 75.0,
310 delta: 5.0,
311 }],
312 };
313 let formatted = format_coverage_delta(&delta);
314 assert!(formatted.contains("↑"));
315 assert!(formatted.contains("a.rs"));
316 }
317
318 #[test]
319 fn coverage_json() {
320 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
321 let json = format_coverage_json(&result);
322 assert!(json.contains("percentage"));
323 assert!(json.contains("80"));
324 }
325
326 #[test]
327 fn empty_result_summary() {
328 let result = CoverageResult::from_files(vec![]);
329 let summary = format_coverage_summary(&result);
330 assert!(summary.contains("Coverage Summary"));
331 assert!(summary.contains("0/0"));
332 }
333
334 #[test]
335 fn branch_coverage_in_summary() {
336 let result = CoverageResult {
337 files: vec![],
338 total_lines: 100,
339 covered_lines: 80,
340 percentage: 80.0,
341 total_branches: 20,
342 covered_branches: 15,
343 branch_percentage: 75.0,
344 };
345 let summary = format_coverage_summary(&result);
346 assert!(summary.contains("Branch Coverage"));
347 }
348}