use std::collections::HashMap;
use std::fmt::Write;
use crate::model::{rate, FileDiffCoverage};
pub struct DiffCoverageReport {
pub diff_files: usize,
pub diff_lines: usize,
pub files: Vec<FileDiffCoverage>,
pub total_covered: usize,
pub total_instrumentable: usize,
pub total_rate: Option<f64>,
pub sha: Option<String>,
}
impl DiffCoverageReport {
#[must_use]
pub fn format(&self, formatter: &dyn ReportFormatter) -> String {
formatter.format(self)
}
#[must_use]
pub fn format_text(&self) -> String {
self.format(&TextFormatter)
}
#[must_use]
pub fn format_markdown(&self) -> String {
self.format(&MarkdownFormatter)
}
}
pub trait ReportFormatter {
fn format(&self, report: &DiffCoverageReport) -> String;
}
pub struct TextFormatter;
impl ReportFormatter for TextFormatter {
fn format(&self, report: &DiffCoverageReport) -> String {
let mut out = String::new();
if report.diff_files == 0 {
out.push_str("No added lines found in diff.\n");
return out;
}
if report.total_instrumentable == 0 {
let lines = report.diff_lines;
let files = report.diff_files;
writeln!(
out,
"{lines} lines added across {files} files — none are instrumentable."
)
.unwrap();
return out;
}
let pct = rate(
report.total_covered as u64,
report.total_instrumentable as u64,
) * 100.0;
let covered = report.total_covered;
let total = report.total_instrumentable;
writeln!(
out,
"Diff coverage: {pct:.1}% ({covered}/{total} lines covered)"
)
.unwrap();
let mut files_with_misses: Vec<_> = report
.files
.iter()
.filter(|f| !f.missed_lines.is_empty())
.collect();
files_with_misses.sort_by(|a, b| a.rate().partial_cmp(&b.rate()).unwrap());
if !files_with_misses.is_empty() {
out.push('\n');
for f in &files_with_misses {
let file_total = f.total();
let file_covered = f.covered_lines.len();
let file_rate = f.rate() * 100.0;
let path = &f.path;
let missed = format_line_ranges(&f.missed_lines);
writeln!(
out,
" {path} {file_covered}/{file_total} ({file_rate:.1}%) missed: {missed}",
)
.unwrap();
}
}
if let Some(rate) = report.total_rate {
out.push('\n');
let pct = rate * 100.0;
writeln!(out, "Full project coverage: {pct:.1}%").unwrap();
}
out
}
}
pub struct MarkdownFormatter;
impl ReportFormatter for MarkdownFormatter {
fn format(&self, report: &DiffCoverageReport) -> String {
let mut md = String::new();
let diff_rate = rate(
report.total_covered as u64,
report.total_instrumentable as u64,
) * 100.0;
writeln!(md, "## Diff Coverage: {diff_rate:.1}%\n").unwrap();
let covered = report.total_covered;
let total = report.total_instrumentable;
write!(md, "**{covered}** of **{total}** diff lines covered").unwrap();
if let Some(ref sha) = report.sha {
let short_sha = if sha.len() > 7 { &sha[..7] } else { sha };
write!(md, " ({short_sha})").unwrap();
}
md.push('\n');
let mut files_with_misses: Vec<&FileDiffCoverage> = report
.files
.iter()
.filter(|f| !f.missed_lines.is_empty())
.collect();
files_with_misses.sort_by(|a, b| a.rate().partial_cmp(&b.rate()).unwrap());
if files_with_misses.is_empty() {
md.push_str("\nAll diff lines are covered! 🎉\n");
} else {
md.push_str("\n| File | Missed | Diff | \n");
md.push_str("|:-----|-------:|------:|\n");
for f in &files_with_misses {
let file_rate = f.rate() * 100.0;
let path = &f.path;
let missed_count = f.missed_lines.len();
writeln!(md, "| `{path}` | {missed_count} | {file_rate:.0}% |").unwrap();
}
md.push_str("\n<details>\n<summary>Missed lines</summary>\n\n");
for f in &files_with_misses {
let path = &f.path;
let ranges = format_line_ranges(&f.missed_lines);
writeln!(md, "**`{path}`**: {ranges}\n").unwrap();
}
md.push_str("</details>\n");
}
md.push('\n');
if let Some(rate) = report.total_rate {
let pct = rate * 100.0;
writeln!(md, "<sub>Full project coverage: **{pct:.1}%**</sub>").unwrap();
}
md.push_str("<sub>[covrs](https://github.com/scttnlsn/covrs)</sub>\n");
md
}
}
pub fn build_report(
conn: &rusqlite::Connection,
diff_lines: &HashMap<String, Vec<u32>>,
sha: Option<&str>,
) -> anyhow::Result<DiffCoverageReport> {
let diff_files = diff_lines.len();
let diff_line_count: usize = diff_lines.values().map(|v| v.len()).sum();
let (files, total_covered, total_instrumentable) = if diff_lines.is_empty() {
(vec![], 0, 0)
} else {
crate::db::diff_coverage_detail(conn, diff_lines)?
};
let total_rate = match crate::db::get_summary(conn) {
Ok(s) if s.total_lines > 0 => Some(s.line_rate()),
Ok(_) => None,
Err(e) => {
eprintln!("Warning: could not compute project coverage: {e}");
None
}
};
Ok(DiffCoverageReport {
diff_files,
diff_lines: diff_line_count,
files,
total_covered,
total_instrumentable,
total_rate,
sha: sha.map(|s| s.to_owned()),
})
}
#[must_use]
pub fn format_line_ranges(lines: &[u32]) -> String {
if lines.is_empty() {
return String::new();
}
let mut ranges: Vec<String> = Vec::new();
let mut start = lines[0];
let mut end = lines[0];
for &line in &lines[1..] {
if line == end + 1 {
end = line;
} else {
if start == end {
ranges.push(start.to_string());
} else {
ranges.push(format!("{start}-{end}"));
}
start = line;
end = line;
}
}
if start == end {
ranges.push(start.to_string());
} else {
ranges.push(format!("{start}-{end}"));
}
ranges.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_line_ranges_empty() {
assert_eq!(format_line_ranges(&[]), "");
}
#[test]
fn test_format_line_ranges_single() {
assert_eq!(format_line_ranges(&[5]), "5");
}
#[test]
fn test_format_line_ranges_consecutive() {
assert_eq!(format_line_ranges(&[1, 2, 3]), "1-3");
}
#[test]
fn test_format_line_ranges_mixed() {
assert_eq!(format_line_ranges(&[1, 3, 4, 5, 10]), "1, 3-5, 10");
}
#[test]
fn test_format_markdown_all_covered() {
let report = DiffCoverageReport {
diff_files: 1,
diff_lines: 10,
files: vec![],
total_covered: 10,
total_instrumentable: 10,
total_rate: Some(0.85),
sha: Some("abc1234def".to_string()),
};
let body = report.format_markdown();
assert!(body.contains("Diff Coverage: 100.0%"));
assert!(body.contains("All diff lines are covered!"));
assert!(body.contains("85.0%"));
assert!(body.contains("[covrs](https://github.com/scttnlsn/covrs)"));
assert!(body.contains("abc1234"));
}
#[test]
fn test_format_markdown_with_misses() {
let report = DiffCoverageReport {
diff_files: 1,
diff_lines: 5,
files: vec![FileDiffCoverage {
path: "src/foo.rs".to_string(),
covered_lines: vec![1, 2, 3],
missed_lines: vec![5, 6],
}],
total_covered: 3,
total_instrumentable: 5,
total_rate: None,
sha: None,
};
let body = report.format_markdown();
assert!(body.contains("60.0%"));
assert!(body.contains("src/foo.rs"));
assert!(body.contains("5-6"));
assert!(body.contains("Missed lines"));
}
#[test]
fn test_format_with_trait() {
let report = DiffCoverageReport {
diff_files: 1,
diff_lines: 5,
files: vec![],
total_covered: 5,
total_instrumentable: 5,
total_rate: None,
sha: None,
};
let text = report.format(&TextFormatter);
assert!(text.contains("Diff coverage: 100.0%"));
let md = report.format(&MarkdownFormatter);
assert!(md.contains("Diff Coverage: 100.0%"));
}
}