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 && 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
144/// Generate an ASCII coverage bar.
145fn 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
153/// Format a threshold check result.
154pub 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
171/// Format a coverage delta for display.
172pub 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
206/// Format coverage result as JSON string.
207pub 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")); // covered
249        assert!(summary.contains("350")); // total
250    }
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}