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 && max_name > 0 {
91 let start = name.ceil_char_boundary(name.len().saturating_sub(max_name - 1));
92 format!("…{}", &name[start..])
93 } else {
94 name.to_string()
95 };
96
97 let bar = coverage_bar(file.percentage(), 7);
98 let _ = writeln!(
99 out,
100 " {:<width$} {:>6} {:>6} {} {:.1}%",
101 display_name,
102 file.total_lines,
103 file.covered_lines,
104 bar,
105 file.percentage(),
106 width = max_name
107 );
108 }
109 let _ = writeln!(out);
110}
111
112fn write_branch_summary(out: &mut String, result: &CoverageResult) {
113 let _ = writeln!(
114 out,
115 " Branch Coverage: {}/{} ({:.1}%)",
116 result.covered_branches, result.total_branches, result.branch_percentage
117 );
118 let _ = writeln!(out);
119}
120
121fn write_uncovered_files(out: &mut String, result: &CoverageResult) {
122 let uncovered: Vec<&FileCoverage> = result
123 .files
124 .iter()
125 .filter(|f| f.covered_lines == 0 && f.total_lines > 0)
126 .collect();
127
128 if uncovered.is_empty() {
129 return;
130 }
131
132 let _ = writeln!(out, " Uncovered Files ({}):", uncovered.len());
133 for file in &uncovered {
134 let _ = writeln!(
135 out,
136 " ⚠ {} ({} lines)",
137 file.path.display(),
138 file.total_lines
139 );
140 }
141 let _ = writeln!(out);
142}
143
144fn coverage_bar(percentage: f64, width: usize) -> String {
146 let filled = ((percentage / 100.0) * width as f64).round() as usize;
147 let filled = filled.min(width);
148 let empty = width - filled;
149
150 format!("│{}{}│", "█".repeat(filled), "░".repeat(empty))
151}
152
153pub fn format_threshold_check(result: &CoverageResult, threshold: f64) -> String {
155 let met = result.meets_threshold(threshold);
156 if met {
157 format!(
158 " ✅ Coverage {:.1}% meets threshold {:.1}%",
159 result.percentage, threshold
160 )
161 } else {
162 format!(
163 " ❌ Coverage {:.1}% is below threshold {:.1}% (need {:.1}% more)",
164 result.percentage,
165 threshold,
166 threshold - result.percentage
167 )
168 }
169}
170
171pub fn format_coverage_delta(delta: &CoverageDelta) -> String {
173 let mut out = String::with_capacity(512);
174
175 let _ = writeln!(out, " Coverage Change: {}", delta.format_delta());
176 let _ = writeln!(out);
177
178 if !delta.file_deltas.is_empty() {
179 let _ = writeln!(out, " Changed Files:");
180 let count = delta.file_deltas.len().min(10);
181 for fd in delta.file_deltas.iter().take(count) {
182 let arrow = if fd.delta > 0.0 { "↑" } else { "↓" };
183 let _ = writeln!(
184 out,
185 " {} {} {:.1}% → {:.1}% ({}{:.1}%)",
186 arrow,
187 fd.path.display(),
188 fd.old_percentage,
189 fd.new_percentage,
190 if fd.delta > 0.0 { "+" } else { "" },
191 fd.delta,
192 );
193 }
194 if delta.file_deltas.len() > count {
195 let _ = writeln!(
196 out,
197 " ... and {} more files",
198 delta.file_deltas.len() - count
199 );
200 }
201 }
202
203 out
204}
205
206pub fn format_coverage_json(result: &CoverageResult) -> String {
208 serde_json::to_string_pretty(result).unwrap_or_else(|_| "{}".to_string())
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::coverage::FileCoverageDelta;
215 use std::collections::HashMap;
216 use std::path::PathBuf;
217
218 fn make_file(path: &str, total: usize, covered: usize) -> FileCoverage {
219 FileCoverage {
220 path: PathBuf::from(path),
221 total_lines: total,
222 covered_lines: covered,
223 uncovered_ranges: Vec::new(),
224 line_hits: HashMap::new(),
225 total_branches: 0,
226 covered_branches: 0,
227 }
228 }
229
230 fn make_result() -> CoverageResult {
231 CoverageResult::from_files(vec![
232 make_file("src/main.rs", 100, 80),
233 make_file("src/lib.rs", 200, 190),
234 make_file("src/util.rs", 50, 0),
235 ])
236 }
237
238 #[test]
239 fn summary_contains_header() {
240 let summary = format_coverage_summary(&make_result());
241 assert!(summary.contains("Coverage Summary"));
242 assert!(summary.contains("Lines:"));
243 }
244
245 #[test]
246 fn summary_contains_totals() {
247 let summary = format_coverage_summary(&make_result());
248 assert!(summary.contains("270")); assert!(summary.contains("350")); }
251
252 #[test]
253 fn summary_contains_files() {
254 let summary = format_coverage_summary(&make_result());
255 assert!(summary.contains("src/main.rs"));
256 assert!(summary.contains("src/lib.rs"));
257 assert!(summary.contains("src/util.rs"));
258 }
259
260 #[test]
261 fn summary_uncovered_files() {
262 let summary = format_coverage_summary(&make_result());
263 assert!(summary.contains("Uncovered Files"));
264 assert!(summary.contains("src/util.rs"));
265 }
266
267 #[test]
268 fn coverage_bar_full() {
269 let bar = coverage_bar(100.0, 5);
270 assert!(bar.contains("█████"));
271 }
272
273 #[test]
274 fn coverage_bar_empty() {
275 let bar = coverage_bar(0.0, 5);
276 assert!(bar.contains("░░░░░"));
277 }
278
279 #[test]
280 fn coverage_bar_half() {
281 let bar = coverage_bar(50.0, 4);
282 assert!(bar.contains("██"));
283 assert!(bar.contains("░░"));
284 }
285
286 #[test]
287 fn threshold_met() {
288 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 85)]);
289 let msg = format_threshold_check(&result, 80.0);
290 assert!(msg.contains("✅"));
291 assert!(msg.contains("meets"));
292 }
293
294 #[test]
295 fn threshold_not_met() {
296 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 70)]);
297 let msg = format_threshold_check(&result, 80.0);
298 assert!(msg.contains("❌"));
299 assert!(msg.contains("below"));
300 }
301
302 #[test]
303 fn delta_format() {
304 let delta = CoverageDelta {
305 line_delta: 5.0,
306 branch_delta: 0.0,
307 file_deltas: vec![FileCoverageDelta {
308 path: PathBuf::from("a.rs"),
309 old_percentage: 70.0,
310 new_percentage: 75.0,
311 delta: 5.0,
312 }],
313 };
314 let formatted = format_coverage_delta(&delta);
315 assert!(formatted.contains("↑"));
316 assert!(formatted.contains("a.rs"));
317 }
318
319 #[test]
320 fn coverage_json() {
321 let result = CoverageResult::from_files(vec![make_file("a.rs", 100, 80)]);
322 let json = format_coverage_json(&result);
323 assert!(json.contains("percentage"));
324 assert!(json.contains("80"));
325 }
326
327 #[test]
328 fn empty_result_summary() {
329 let result = CoverageResult::from_files(vec![]);
330 let summary = format_coverage_summary(&result);
331 assert!(summary.contains("Coverage Summary"));
332 assert!(summary.contains("0/0"));
333 }
334
335 #[test]
336 fn branch_coverage_in_summary() {
337 let result = CoverageResult {
338 files: vec![],
339 total_lines: 100,
340 covered_lines: 80,
341 percentage: 80.0,
342 total_branches: 20,
343 covered_branches: 15,
344 branch_percentage: 75.0,
345 };
346 let summary = format_coverage_summary(&result);
347 assert!(summary.contains("Branch Coverage"));
348 }
349}