Skip to main content

testx/coverage/
display.rs

1//! Coverage display and formatting.
2//!
3//! Pretty-prints coverage results, highlights uncovered files,
4//! and shows delta vs previous run.
5
6use std::fmt::Write;
7
8use crate::coverage::{CoverageDelta, CoverageResult, FileCoverage};
9
10/// Format a full coverage summary for terminal output.
11pub 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    // Find max filename length for alignment
54    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
143/// Generate an ASCII coverage bar.
144fn 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
152/// Format a threshold check result.
153pub 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
170/// Format a coverage delta for display.
171pub 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
205/// Format coverage result as JSON string.
206pub 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")); // covered
248        assert!(summary.contains("350")); // total
249    }
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}