1use colored::Colorize;
2use comfy_table::{Cell, Color, ContentArrangement, Table};
3
4use crate::diagnostics::{DiagnosticsData, Issue, Severity};
5use crate::model::{CoverageData, FileDiff, QualityGateResult};
6
7pub 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 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 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
89pub 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
140pub 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
176pub 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
217pub 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
235pub fn render_gate_json(result: &QualityGateResult) -> String {
237 serde_json::to_string_pretty(result).unwrap_or_default()
238}
239
240pub 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 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 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 out.push_str("\n<!-- covy -->\n");
382 out
383}
384
385pub 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
428fn 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
476fn 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}