Skip to main content

covy_core/
report.rs

1use colored::Colorize;
2use comfy_table::{Cell, Color, ContentArrangement, Table};
3
4use crate::diagnostics::{DiagnosticsData, Issue, Severity};
5use crate::model::{CoverageData, FileDiff, QualityGateResult};
6
7/// Render coverage report to terminal.
8///
9/// `below_threshold` — if `Some(t)`, only files with coverage < t% are shown.
10/// `summary_only` — if true, skip the per-file table and print only the total.
11pub fn render_terminal(
12    coverage: &CoverageData,
13    show_missing: bool,
14    sort_by: &str,
15    below_threshold: Option<f64>,
16    summary_only: bool,
17) {
18    if !summary_only {
19        let mut table = Table::new();
20        table.set_content_arrangement(ContentArrangement::Dynamic);
21
22        let mut headers = vec!["File", "Lines", "Covered", "Coverage"];
23        if show_missing {
24            headers.push("Missing");
25        }
26        table.set_header(headers);
27
28        let mut entries: Vec<_> = coverage.files.iter().collect();
29
30        // Apply --below filter
31        if let Some(threshold) = below_threshold {
32            entries.retain(|(_, fc)| fc.line_coverage_pct().unwrap_or(0.0) < threshold);
33        }
34
35        match sort_by {
36            "coverage" => entries.sort_by(|a, b| {
37                let pa = a.1.line_coverage_pct().unwrap_or(0.0);
38                let pb = b.1.line_coverage_pct().unwrap_or(0.0);
39                pa.partial_cmp(&pb).unwrap()
40            }),
41            "name" => entries.sort_by_key(|(k, _)| (*k).clone()),
42            _ => entries.sort_by_key(|(k, _)| (*k).clone()),
43        }
44
45        for (path, fc) in &entries {
46            let pct = fc.line_coverage_pct().unwrap_or(0.0);
47            let color = coverage_color(pct);
48            let pct_str = format!("{pct:.1}%");
49
50            let mut row = vec![
51                Cell::new(path),
52                Cell::new(fc.lines_instrumented.len()),
53                Cell::new(fc.lines_covered.len()),
54                Cell::new(&pct_str).fg(color),
55            ];
56
57            if show_missing {
58                let missing = &fc.lines_instrumented - &fc.lines_covered;
59                let missing_str = format_line_ranges(&missing);
60                row.push(Cell::new(missing_str));
61            }
62
63            table.add_row(row);
64        }
65
66        println!("{table}");
67    }
68
69    // Summary
70    if let Some(total) = coverage.total_coverage_pct() {
71        let color_code = if total >= 80.0 {
72            "green"
73        } else if total >= 60.0 {
74            "yellow"
75        } else {
76            "red"
77        };
78        let summary = format!("Total coverage: {total:.1}%");
79        match color_code {
80            "green" => println!("\n{}", summary.green().bold()),
81            "yellow" => println!("\n{}", summary.yellow().bold()),
82            _ => println!("\n{}", summary.red().bold()),
83        }
84    } else {
85        println!("\n{}", "No coverage data available.".dimmed());
86    }
87}
88
89/// Render diagnostics issues to terminal.
90pub fn render_issues_terminal(diagnostics: &DiagnosticsData, diffs: Option<&[FileDiff]>) {
91    let mut issues = collect_issues(diagnostics, diffs);
92    if issues.is_empty() {
93        println!("\n{}", "No diagnostics issues found.".green().bold());
94        return;
95    }
96
97    issues.sort_by(|a, b| {
98        a.path
99            .cmp(&b.path)
100            .then_with(|| a.line.cmp(&b.line))
101            .then_with(|| b.severity.cmp(&a.severity))
102    });
103
104    let mut table = Table::new();
105    table.set_content_arrangement(ContentArrangement::Dynamic);
106    table.set_header(["File", "Line", "Severity", "Rule", "Message"]);
107
108    for issue in &issues {
109        let severity_color = match issue.severity {
110            Severity::Error => Color::Red,
111            Severity::Warning => Color::Yellow,
112            Severity::Note => Color::Blue,
113        };
114        table.add_row(vec![
115            Cell::new(&issue.path),
116            Cell::new(issue.line),
117            Cell::new(issue.severity.to_string()).fg(severity_color),
118            Cell::new(&issue.rule_id),
119            Cell::new(issue.message.replace('\n', " ")),
120        ]);
121    }
122
123    println!("\n{table}");
124
125    let (errors, warnings, notes) = severity_counts(&issues);
126    let scope = if diffs.is_some() {
127        "changed lines"
128    } else {
129        "all files"
130    };
131    println!(
132        "Issues on {scope}: errors={errors}, warnings={warnings}, notes={notes}, total={}",
133        issues.len()
134    );
135    if diffs.is_some() {
136        println!("Total issues in report: {}", diagnostics.total_issues());
137    }
138}
139
140/// Render quality gate result to terminal.
141pub fn render_gate_result(result: &QualityGateResult) {
142    println!();
143    if result.passed {
144        println!("{}", "╔══════════════════════════════════╗".green());
145        println!("{}", "║      Quality Gate: PASSED        ║".green().bold());
146        println!("{}", "╚══════════════════════════════════╝".green());
147    } else {
148        println!("{}", "╔══════════════════════════════════╗".red());
149        println!("{}", "║      Quality Gate: FAILED        ║".red().bold());
150        println!("{}", "╚══════════════════════════════════╝".red());
151    }
152
153    if let Some(pct) = result.total_coverage_pct {
154        println!("  Total coverage:         {pct:.1}%");
155    }
156    if let Some(pct) = result.changed_coverage_pct {
157        println!("  Changed lines coverage: {pct:.1}%");
158    }
159    if let Some(pct) = result.new_file_coverage_pct {
160        println!("  New file coverage:      {pct:.1}%");
161    }
162    if let Some(counts) = &result.issue_counts {
163        println!(
164            "  Changed issues:         errors={}, warnings={}, notes={}",
165            counts.changed_errors, counts.changed_warnings, counts.changed_notes
166        );
167        println!("  Total issues parsed:    {}", counts.total_issues);
168    }
169
170    for violation in &result.violations {
171        println!("  {} {violation}", "✗".red());
172    }
173    println!();
174}
175
176/// Render coverage data as JSON.
177///
178/// `below_threshold` — if `Some(t)`, only files with coverage < t% are included.
179/// `summary_only` — if true, omit the `files` array entirely.
180pub fn render_json(
181    coverage: &CoverageData,
182    below_threshold: Option<f64>,
183    summary_only: bool,
184) -> String {
185    let mut report = serde_json::json!({
186        "total_coverage_pct": coverage.total_coverage_pct().unwrap_or(0.0),
187    });
188
189    if !summary_only {
190        let mut files = Vec::new();
191        for (path, fc) in &coverage.files {
192            let pct = fc.line_coverage_pct().unwrap_or(0.0);
193            if let Some(threshold) = below_threshold {
194                if pct >= threshold {
195                    continue;
196                }
197            }
198            let covered: Vec<u32> = fc.lines_covered.iter().collect();
199            let instrumented: Vec<u32> = fc.lines_instrumented.iter().collect();
200            let missing: Vec<u32> = (&fc.lines_instrumented - &fc.lines_covered)
201                .iter()
202                .collect();
203            files.push(serde_json::json!({
204                "path": path,
205                "lines_covered": covered.len(),
206                "lines_instrumented": instrumented.len(),
207                "coverage_pct": pct,
208                "missing_lines": missing,
209            }));
210        }
211        report["files"] = serde_json::json!(files);
212    }
213
214    serde_json::to_string_pretty(&report).unwrap_or_default()
215}
216
217/// Render diagnostics data as JSON.
218pub fn render_issues_json(diagnostics: &DiagnosticsData) -> String {
219    let all = collect_issues(diagnostics, None);
220    let (errors, warnings, notes) = severity_counts(&all);
221
222    let payload = serde_json::json!({
223        "total_issues": diagnostics.total_issues(),
224        "counts": {
225            "errors": errors,
226            "warnings": warnings,
227            "notes": notes,
228        },
229        "issues": all,
230    });
231
232    serde_json::to_string_pretty(&payload).unwrap_or_default()
233}
234
235/// Render quality gate result as JSON.
236pub fn render_gate_json(result: &QualityGateResult) -> String {
237    serde_json::to_string_pretty(result).unwrap_or_default()
238}
239
240/// Render a markdown coverage report suitable for PR comments.
241pub fn render_markdown(
242    coverage: &CoverageData,
243    gate_result: &QualityGateResult,
244    diffs: &[FileDiff],
245    show_missing: bool,
246    diagnostics: Option<&DiagnosticsData>,
247) -> String {
248    let mut out = String::new();
249    out.push_str("## Coverage Report\n\n");
250
251    // Summary table
252    out.push_str("| Metric | Value | Threshold | Status |\n");
253    out.push_str("|--------|-------|-----------|--------|\n");
254
255    if let Some(total) = gate_result.total_coverage_pct {
256        let threshold = gate_result
257            .violations
258            .iter()
259            .find(|v| v.contains("Total coverage"));
260        let (thresh_str, status) = if let Some(v) = threshold {
261            let t = extract_threshold(v).unwrap_or_default();
262            (format!("{t:.1}%"), "failed")
263        } else {
264            ("—".into(), "passed")
265        };
266        out.push_str(&format!(
267            "| Total | {total:.1}% | {thresh_str} | {status} |\n"
268        ));
269    }
270
271    if let Some(changed) = gate_result.changed_coverage_pct {
272        let threshold = gate_result
273            .violations
274            .iter()
275            .find(|v| v.contains("Changed lines"));
276        let (thresh_str, status) = if let Some(v) = threshold {
277            let t = extract_threshold(v).unwrap_or_default();
278            (format!("{t:.1}%"), "failed")
279        } else {
280            ("—".into(), "passed")
281        };
282        out.push_str(&format!(
283            "| Changed Lines | {changed:.1}% | {thresh_str} | {status} |\n"
284        ));
285    }
286
287    if let Some(new_file) = gate_result.new_file_coverage_pct {
288        let threshold = gate_result
289            .violations
290            .iter()
291            .find(|v| v.contains("New file"));
292        let (thresh_str, status) = if let Some(v) = threshold {
293            let t = extract_threshold(v).unwrap_or_default();
294            (format!("{t:.1}%"), "failed")
295        } else {
296            ("—".into(), "passed")
297        };
298        out.push_str(&format!(
299            "| New Files | {new_file:.1}% | {thresh_str} | {status} |\n"
300        ));
301    }
302
303    // Changed files detail
304    let changed_files: Vec<_> = diffs
305        .iter()
306        .filter(|d| coverage.files.contains_key(&d.path))
307        .collect();
308
309    if !changed_files.is_empty() {
310        out.push_str(&format!(
311            "\n<details><summary>Changed Files ({})</summary>\n\n",
312            changed_files.len()
313        ));
314
315        let mut headers = "| File | Coverage | Lines |".to_string();
316        if show_missing {
317            headers.push_str(" Missing |");
318        }
319        out.push_str(&headers);
320        out.push('\n');
321
322        let mut sep = "|------|----------|-------|".to_string();
323        if show_missing {
324            sep.push_str("---------|");
325        }
326        out.push_str(&sep);
327        out.push('\n');
328
329        for diff in &changed_files {
330            if let Some(fc) = coverage.files.get(&diff.path) {
331                let pct = fc.line_coverage_pct().unwrap_or(0.0);
332                let changed_covered = (&diff.changed_lines & &fc.lines_covered).len();
333                let changed_total = (&diff.changed_lines & &fc.lines_instrumented).len();
334                let mut row = format!(
335                    "| {} | {pct:.1}% | {changed_covered}/{changed_total} |",
336                    diff.path
337                );
338                if show_missing {
339                    let missing = &fc.lines_instrumented - &fc.lines_covered;
340                    let changed_missing = &diff.changed_lines & &missing;
341                    row.push_str(&format!(" {} |", format_line_ranges(&changed_missing)));
342                }
343                out.push_str(&row);
344                out.push('\n');
345            }
346        }
347
348        out.push_str("\n</details>\n");
349    }
350
351    if let Some(diag) = diagnostics {
352        let changed = collect_issues(diag, Some(diffs));
353        let (errors, warnings, notes) = severity_counts(&changed);
354
355        out.push_str("\n## Issues\n\n");
356        out.push_str(&format!(
357            "Changed-line issues: errors={errors}, warnings={warnings}, notes={notes}, total={}\n\n",
358            changed.len()
359        ));
360
361        if !changed.is_empty() {
362            out.push_str("| File | Line | Severity | Rule | Message |\n");
363            out.push_str("|------|------|----------|------|---------|\n");
364            for issue in changed.iter().take(50) {
365                let msg = issue.message.replace('\n', " ").replace('|', "\\|");
366                out.push_str(&format!(
367                    "| {} | {} | {} | {} | {} |\n",
368                    issue.path, issue.line, issue.severity, issue.rule_id, msg
369                ));
370            }
371            if changed.len() > 50 {
372                out.push_str(&format!(
373                    "\n_{} additional issues omitted._\n",
374                    changed.len() - 50
375                ));
376            }
377        }
378    }
379
380    // Footer
381    out.push_str("\n<!-- covy -->\n");
382    out
383}
384
385/// Render GitHub Actions annotations for uncovered changed lines and diagnostics issues.
386pub fn render_github_annotations(
387    coverage: &CoverageData,
388    diffs: &[FileDiff],
389    gate_result: &QualityGateResult,
390    diagnostics: Option<&DiagnosticsData>,
391) {
392    for diff in diffs {
393        if let Some(fc) = coverage.files.get(&diff.path) {
394            let missing = &fc.lines_instrumented - &fc.lines_covered;
395            let uncovered_changed = &diff.changed_lines & &missing;
396
397            for line in uncovered_changed.iter() {
398                println!(
399                    "::warning file={},line={line}::Line not covered by tests",
400                    diff.path
401                );
402            }
403        }
404    }
405
406    if let Some(diag) = diagnostics {
407        for issue in collect_issues(diag, Some(diffs)) {
408            let level = match issue.severity {
409                Severity::Error => "error",
410                Severity::Warning => "warning",
411                Severity::Note => "notice",
412            };
413            let msg = issue.message.replace('\n', " ");
414            println!(
415                "::{level} file={},line={}::[{}:{}] {}",
416                issue.path, issue.line, issue.source, issue.rule_id, msg
417            );
418        }
419    }
420
421    if !gate_result.passed {
422        for violation in &gate_result.violations {
423            println!("::error::Quality gate failed: {violation}");
424        }
425    }
426}
427
428/// Extract the threshold number from a violation message like "... below threshold 90.0%"
429fn extract_threshold(violation: &str) -> Option<f64> {
430    violation
431        .rsplit("threshold ")
432        .next()
433        .and_then(|s| s.trim_end_matches('%').parse().ok())
434}
435
436fn coverage_color(pct: f64) -> Color {
437    if pct >= 80.0 {
438        Color::Green
439    } else if pct >= 60.0 {
440        Color::Yellow
441    } else {
442        Color::Red
443    }
444}
445
446fn collect_issues<'a>(
447    diagnostics: &'a DiagnosticsData,
448    diffs: Option<&[FileDiff]>,
449) -> Vec<&'a Issue> {
450    match diffs {
451        Some(d) => diagnostics.issues_on_changed_lines(d),
452        None => diagnostics
453            .issues_by_file
454            .values()
455            .flat_map(|issues| issues.iter())
456            .collect(),
457    }
458}
459
460fn severity_counts(issues: &[&Issue]) -> (usize, usize, usize) {
461    let mut errors = 0usize;
462    let mut warnings = 0usize;
463    let mut notes = 0usize;
464
465    for issue in issues {
466        match issue.severity {
467            Severity::Error => errors += 1,
468            Severity::Warning => warnings += 1,
469            Severity::Note => notes += 1,
470        }
471    }
472
473    (errors, warnings, notes)
474}
475
476/// Format a roaring bitmap as compact line ranges (e.g., "1-3, 7, 10-15").
477fn format_line_ranges(bitmap: &roaring::RoaringBitmap) -> String {
478    if bitmap.is_empty() {
479        return String::new();
480    }
481    let lines: Vec<u32> = bitmap.iter().collect();
482    let mut ranges = Vec::new();
483    let mut start = lines[0];
484    let mut end = lines[0];
485
486    for &line in &lines[1..] {
487        if line == end + 1 {
488            end = line;
489        } else {
490            if start == end {
491                ranges.push(format!("{start}"));
492            } else {
493                ranges.push(format!("{start}-{end}"));
494            }
495            start = line;
496            end = line;
497        }
498    }
499    if start == end {
500        ranges.push(format!("{start}"));
501    } else {
502        ranges.push(format!("{start}-{end}"));
503    }
504
505    ranges.join(", ")
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::diagnostics::{DiagnosticsData, Issue};
512
513    #[test]
514    fn test_format_line_ranges() {
515        let mut bm = roaring::RoaringBitmap::new();
516        bm.insert(1);
517        bm.insert(2);
518        bm.insert(3);
519        bm.insert(7);
520        bm.insert(10);
521        bm.insert(11);
522        assert_eq!(format_line_ranges(&bm), "1-3, 7, 10-11");
523    }
524
525    #[test]
526    fn test_format_line_ranges_empty() {
527        let bm = roaring::RoaringBitmap::new();
528        assert_eq!(format_line_ranges(&bm), "");
529    }
530
531    #[test]
532    fn test_render_json() {
533        let mut coverage = CoverageData::new();
534        let mut fc = crate::model::FileCoverage::new();
535        fc.lines_covered.insert(1);
536        fc.lines_covered.insert(2);
537        fc.lines_instrumented.insert(1);
538        fc.lines_instrumented.insert(2);
539        fc.lines_instrumented.insert(3);
540        coverage.files.insert("test.rs".to_string(), fc);
541
542        let json = render_json(&coverage, None, false);
543        assert!(json.contains("test.rs"));
544        assert!(json.contains("66."));
545    }
546
547    #[test]
548    fn test_render_issues_json() {
549        let mut diagnostics = DiagnosticsData::new();
550        diagnostics.issues_by_file.insert(
551            "src/main.rs".to_string(),
552            vec![Issue {
553                path: "src/main.rs".to_string(),
554                line: 5,
555                column: None,
556                end_line: None,
557                severity: Severity::Warning,
558                rule_id: "w1".to_string(),
559                message: "x".to_string(),
560                source: "tool".to_string(),
561                fingerprint: "fp1".to_string(),
562            }],
563        );
564
565        let json = render_issues_json(&diagnostics);
566        assert!(json.contains("total_issues"));
567        assert!(json.contains("src/main.rs"));
568    }
569}