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)
}
}
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 = if let Some(ref sha) = report.sha {
format_line_ranges_linked(&f.missed_lines, sha, path)
} else {
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(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_linked(lines: &[u32], sha: &str, path: &str) -> String {
if lines.is_empty() {
return String::new();
}
debug_assert!(
lines.windows(2).all(|w| w[0] < w[1]),
"format_line_ranges_linked requires sorted, deduplicated input"
);
let link = |line: u32| -> String { format!("[{line}](../blob/{sha}/{path}#L{line})") };
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(link(start));
} else {
ranges.push(format!("{}-{}", link(start), link(end)));
}
start = line;
end = line;
}
}
if start == end {
ranges.push(link(start));
} else {
ranges.push(format!("{}-{}", link(start), link(end)));
}
ranges.join(", ")
}
#[must_use]
pub fn format_line_ranges(lines: &[u32]) -> String {
if lines.is_empty() {
return String::new();
}
debug_assert!(
lines.windows(2).all(|w| w[0] < w[1]),
"format_line_ranges requires sorted, deduplicated input"
);
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_line_ranges_linked_empty() {
assert_eq!(format_line_ranges_linked(&[], "abc123", "src/foo.rs"), "");
}
#[test]
fn test_format_line_ranges_linked_single() {
assert_eq!(
format_line_ranges_linked(&[5], "abc123", "src/foo.rs"),
"[5](../blob/abc123/src/foo.rs#L5)"
);
}
#[test]
fn test_format_line_ranges_linked_consecutive() {
assert_eq!(
format_line_ranges_linked(&[1, 2, 3], "abc123", "src/foo.rs"),
"[1](../blob/abc123/src/foo.rs#L1)-[3](../blob/abc123/src/foo.rs#L3)"
);
}
#[test]
fn test_format_line_ranges_linked_mixed() {
assert_eq!(
format_line_ranges_linked(&[1, 3, 4, 5, 10], "abc123", "src/foo.rs"),
"[1](../blob/abc123/src/foo.rs#L1), [3](../blob/abc123/src/foo.rs#L3)-[5](../blob/abc123/src/foo.rs#L5), [10](../blob/abc123/src/foo.rs#L10)"
);
}
#[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(&MarkdownFormatter);
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(&MarkdownFormatter);
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_markdown_with_misses_linked() {
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: Some("abc1234def".to_string()),
};
let body = report.format(&MarkdownFormatter);
assert!(body.contains(
"[5](../blob/abc1234def/src/foo.rs#L5)-[6](../blob/abc1234def/src/foo.rs#L6)"
));
}
#[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%"));
}
}