use super::common::{contains_token_match, is_non_code_file};
use crate::git::{Diff, FileChange, Repository};
use crate::paths::normalize_path_display;
use crate::regression::tests::is_test_file;
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CoverageMatchTier {
High, Medium, Low, }
impl std::fmt::Display for CoverageMatchTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CoverageMatchTier::High => write!(f, "high"),
CoverageMatchTier::Medium => write!(f, "medium"),
CoverageMatchTier::Low => write!(f, "low"),
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageDelta {
pub total_source: usize,
pub covered_count: usize,
pub pct: u32,
pub uncovered: Vec<CoverageFile>,
pub covered: Vec<CoveragePair>,
pub non_code_count: usize,
pub ghost_tests: Vec<CoveragePair>,
}
impl CoverageDelta {
pub fn from_signal(signal: &CoverageSignal) -> Self {
CoverageDelta {
total_source: signal.total_source_files,
covered_count: signal.covered_count,
pct: signal.coverage_pct,
uncovered: signal
.uncovered_files
.iter()
.map(|p| CoverageFile {
status: 'M',
path: p.clone(),
})
.collect(),
covered: signal
.covered_files
.iter()
.map(|(src, test, tier)| CoveragePair {
src_status: 'M',
src_path: src.clone(),
test_status: 'M',
test_path: test.clone(),
tier: *tier,
})
.collect(),
ghost_tests: signal
.ghost_tests
.iter()
.map(|(src, ghost)| CoveragePair {
src_status: 'D',
src_path: src.clone(),
test_status: 'M',
test_path: ghost.clone(),
tier: CoverageMatchTier::High, })
.collect(),
non_code_count: signal.non_code_count,
}
}
}
#[derive(Debug, Clone)]
pub struct CoverageFile {
pub status: char,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct CoveragePair {
pub src_status: char,
pub src_path: String,
pub test_status: char,
pub test_path: String,
#[allow(dead_code)]
pub tier: CoverageMatchTier,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CoverageSignal {
pub covered_files: Vec<(String, String, CoverageMatchTier)>, pub uncovered_files: Vec<String>,
pub total_source_files: usize,
pub covered_count: usize,
pub coverage_pct: u32,
pub non_code_count: usize,
pub has_rust_inline_tests: bool,
pub rust_uncovered_count: usize,
pub inline_tests_checked: bool,
pub inline_tested_count: usize,
pub confidence: &'static str,
pub limitations: Vec<&'static str>,
pub ghost_tests: Vec<(String, String)>,
}
pub fn compute_coverage_signal(
diffs: &[Diff],
repo_root: Option<&Path>,
repo: Option<&Repository>,
) -> CoverageSignal {
let mut seen_paths = HashSet::new();
let all_files: Vec<&FileChange> = diffs
.iter()
.flat_map(|d| &d.files)
.filter(|f| seen_paths.insert(f.path.as_str()))
.collect();
let mut source_files = Vec::new();
let mut test_files = Vec::new();
let mut deleted_source_files = Vec::new();
let mut deleted_test_files = Vec::new();
let mut non_code_count = 0usize;
for fc in &all_files {
if is_test_file(&fc.path) {
if fc.status == crate::git::FileStatus::Deleted {
deleted_test_files.push(*fc);
} else {
test_files.push(*fc);
}
} else if is_non_code_file(&fc.path) {
non_code_count += 1;
} else if fc.status == crate::git::FileStatus::Deleted {
deleted_source_files.push(*fc);
} else {
source_files.push(*fc);
}
}
let mut covered: Vec<(&FileChange, &FileChange, CoverageMatchTier)> = Vec::new();
let mut uncovered: Vec<&FileChange> = Vec::new();
for src in &source_files {
if let Some((test, tier)) = find_matching_test(src, &test_files) {
covered.push((*src, test, tier));
} else {
uncovered.push(*src);
}
}
let mut used_import_recovery = false;
if repo_root.is_some() || repo.is_some() {
let target_commit = diffs.first().map(|d| d.target_commit_id.as_str());
let mut test_contents: HashMap<String, String> = HashMap::new();
for test in &test_files {
let content = if let (Some(r), Some(commit)) = (repo, target_commit) {
r.file_at_commit(commit, &test.path).ok()
} else if let Some(root) = repo_root {
std::fs::read_to_string(root.join(&test.path)).ok()
} else {
None
};
if let Some(c) = content {
test_contents.insert(test.path.clone(), c);
}
}
let mut import_recovered: Vec<(&FileChange, &FileChange, CoverageMatchTier)> = Vec::new();
for src in &uncovered {
if let Some((test, tier)) = find_test_by_import(src, &test_files, &test_contents) {
import_recovered.push((*src, test, tier));
}
}
if !import_recovered.is_empty() {
used_import_recovery = true;
for (src, test, tier) in &import_recovered {
covered.push((*src, *test, *tier));
}
let recovered_paths: HashSet<&str> = import_recovered
.iter()
.map(|(s, _, _)| s.path.as_str())
.collect();
uncovered.retain(|s| !recovered_paths.contains(s.path.as_str()));
}
}
let mut inline_tested_paths: Vec<String> = Vec::new();
let mut inline_tests_checked = false;
if repo_root.is_some() || repo.is_some() {
inline_tests_checked = true;
let target_commit = diffs.first().map(|d| d.target_commit_id.as_str());
let mut inline_set: HashSet<&str> = HashSet::new();
for src in &uncovered {
if !src.path.ends_with(".rs") {
continue;
}
let content = if let (Some(r), Some(commit)) = (repo, target_commit) {
r.file_at_commit(commit, &src.path).ok()
} else if let Some(root) = repo_root {
std::fs::read_to_string(root.join(&src.path)).ok()
} else {
None
};
if content.as_deref().is_some_and(has_inline_rust_tests) {
inline_set.insert(src.path.as_str());
inline_tested_paths.push(src.path.clone());
}
}
if !inline_set.is_empty() {
uncovered.retain(|s| !inline_set.contains(s.path.as_str()));
}
}
let inline_tested_count = inline_tested_paths.len();
let mut all_tests_refs: Vec<&FileChange> = test_files.to_vec();
all_tests_refs.extend(deleted_test_files.iter().copied());
let mut ghost_tests = Vec::new();
for src in &deleted_source_files {
if find_matching_test(src, &all_tests_refs).is_none() {
if let Some(root) = repo_root
&& let Some(ghost) = find_ghost_test_on_disk(&src.path, root)
{
ghost_tests.push((src.path.clone(), ghost));
}
}
}
let total = source_files.len();
let covered_count = covered.len() + inline_tested_count;
let pct = if total > 0 {
(covered_count as f64 / total as f64 * 100.0) as u32
} else {
100
};
let has_rust = source_files.iter().any(|f| f.path.ends_with(".rs"));
let rust_uncovered = uncovered.iter().filter(|f| f.path.ends_with(".rs")).count();
let has_low = covered.iter().any(|(_, _, t)| *t == CoverageMatchTier::Low);
let has_medium = covered
.iter()
.any(|(_, _, t)| *t == CoverageMatchTier::Medium);
let mut limitations = Vec::new();
limitations.push("File-name heuristic, not actual code coverage");
if rust_uncovered > 0 && !inline_tests_checked {
limitations.push("Inline #[cfg(test)] modules not detected by filename matching");
}
if used_import_recovery {
limitations.push(
"Import-based recovery uses word-boundary matching (may miss complex re-exports)",
);
}
if has_low {
limitations.push("Some matches use keyword overlap only (low confidence, verify manually)");
}
let confidence = if rust_uncovered > 0 || used_import_recovery || has_medium || has_low {
"medium"
} else {
"high"
};
let norm = |p: &str| -> String {
match repo_root {
Some(root) => normalize_path_display(p, root),
None => p.to_string(),
}
};
let mut covered_files: Vec<(String, String, CoverageMatchTier)> = covered
.iter()
.map(|(s, t, tier)| (norm(&s.path), norm(&t.path), *tier))
.collect();
for path in &inline_tested_paths {
covered_files.push((
path.clone(),
"(inline #[cfg(test)])".to_string(),
CoverageMatchTier::High,
));
}
covered_files.sort();
let mut uncovered_files: Vec<String> = uncovered.iter().map(|s| norm(&s.path)).collect();
uncovered_files.sort();
let mut ghost_tests_sorted: Vec<(String, String)> = ghost_tests
.iter()
.map(|(src, ghost)| (norm(src), norm(ghost)))
.collect();
ghost_tests_sorted.sort();
CoverageSignal {
covered_files,
uncovered_files,
ghost_tests: ghost_tests_sorted,
total_source_files: total,
covered_count,
coverage_pct: pct,
non_code_count,
has_rust_inline_tests: inline_tested_count > 0 || has_rust,
rust_uncovered_count: rust_uncovered,
inline_tests_checked,
inline_tested_count,
confidence,
limitations,
}
}
fn has_inline_rust_tests(content: &str) -> bool {
let mut has_test_mod = false;
let mut has_test_attr = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("mod tests") || trimmed.starts_with("pub mod tests") {
has_test_mod = true;
}
if trimmed.starts_with("#[test]") || trimmed.starts_with("#[tokio::test]") {
has_test_attr = true;
}
if has_test_mod && has_test_attr {
return true;
}
}
false
}
pub fn generate_coverage_delta(dir: &Path, signal: &CoverageSignal) -> Result<()> {
let pct = signal.coverage_pct;
let mut output = String::new();
output.push_str("# Coverage Delta (heuristic)\n");
for lim in &signal.limitations {
let _ = writeln!(output, "# {}", lim);
}
let _ = writeln!(output, "# Confidence: {}\n", signal.confidence);
if !signal.uncovered_files.is_empty() {
output.push_str(
"MODIFIED_NO_TEST_CHANGE (actionable - source code altered without tests):\n",
);
for path in &signal.uncovered_files {
let _ = writeln!(output, " {}", path);
}
if signal.rust_uncovered_count > 0 && !signal.inline_tests_checked {
let _ = writeln!(
output,
"Note: {} .rs file(s) may contain inline #[cfg(test)] modules not detected by this heuristic.",
signal.rust_uncovered_count
);
}
output.push('\n');
}
if !signal.ghost_tests.is_empty() {
output.push_str(
"ORPHANED_TEST_DETECTED (diff hygiene - source dropped but test file left behind):\n",
);
for (src, ghost) in &signal.ghost_tests {
let _ = writeln!(output, " source: {} -> likely ghost test: {}", src, ghost);
}
output.push('\n');
}
let strong_matches: Vec<_> = signal
.covered_files
.iter()
.filter(|(_, _, tier)| *tier != CoverageMatchTier::Low)
.collect();
let weak_matches: Vec<_> = signal
.covered_files
.iter()
.filter(|(_, _, tier)| *tier == CoverageMatchTier::Low)
.collect();
if !strong_matches.is_empty() {
output.push_str("HAS_TEST_CHANGE (matching test file found in diff):\n");
for (src, test, _tier) in &strong_matches {
let _ = writeln!(output, " {} <-> {}", src, test);
}
output.push('\n');
}
if !weak_matches.is_empty() {
output.push_str(
"WEAK_TEST_MATCH (low confidence - keyword overlap only, verify manually):\n",
);
for (src, test, _tier) in &weak_matches {
let _ = writeln!(output, " {} <-> {}", src, test);
}
output.push('\n');
}
let _ = writeln!(
output,
"Summary: {}/{} changed code files have matching test changes ({}%)",
signal.covered_count, signal.total_source_files, pct
);
if signal.inline_tested_count > 0 {
let _ = writeln!(
output,
"Recovered {} .rs file(s) as covered via inline #[cfg(test)] modules.",
signal.inline_tested_count
);
}
if signal.non_code_count > 0 {
let _ = writeln!(
output,
"Non-code changes (assets/i18n/config/docs): {} files excluded from coverage",
signal.non_code_count
);
}
fs::write(dir.join("coverage-delta.txt"), output)?;
Ok(())
}
fn find_matching_test<'a>(
source: &FileChange,
test_files: &[&'a FileChange],
) -> Option<(&'a FileChange, CoverageMatchTier)> {
let src_stem = Path::new(&source.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let src_parent = Path::new(&source.path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
for test in test_files {
let test_stem = Path::new(&test.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
let test_base = test_stem
.strip_suffix("_test")
.or_else(|| test_stem.strip_suffix(".test"))
.or_else(|| test_stem.strip_suffix(".spec"))
.or_else(|| test_stem.strip_prefix("test_"))
.unwrap_or(test_stem);
if test_base == src_stem {
return Some((test, CoverageMatchTier::High));
}
}
for test in test_files {
if test.path.contains("tests/") || test.path.contains("__tests__/") {
let test_filename = Path::new(&test.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if test_filename == src_stem {
return Some((test, CoverageMatchTier::High));
}
}
}
for test in test_files {
let test_parent = Path::new(&test.path)
.parent()
.and_then(|p| p.to_str())
.unwrap_or("");
let test_stem = Path::new(&test.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if test_parent == src_parent && test_stem == "tests" {
return Some((test, CoverageMatchTier::Medium));
}
if let Some(stripped) = test_parent.strip_suffix("/tests")
&& stripped == src_parent
{
return Some((test, CoverageMatchTier::Medium));
}
if let Some(test_sub) = test_parent.strip_prefix("tests/")
&& !src_parent.is_empty()
&& (src_parent.ends_with(test_sub) || test_sub.ends_with(src_parent))
{
return Some((test, CoverageMatchTier::Medium));
}
}
let src_segments: Vec<&str> = source
.path
.split('/')
.filter(|s| !matches!(*s, "src" | "core" | "lib" | "mod.rs" | "app"))
.collect();
if src_segments.len() >= 2 {
let mut best_match: Option<(&'a FileChange, usize)> = None;
for test in test_files {
let test_lower = test.path.to_lowercase();
let overlap = src_segments
.iter()
.filter(|seg| {
let seg_lower = seg.to_lowercase();
let seg_clean = seg_lower
.strip_suffix(".rs")
.or_else(|| seg_lower.strip_suffix(".py"))
.or_else(|| seg_lower.strip_suffix(".ts"))
.or_else(|| seg_lower.strip_suffix(".tsx"))
.or_else(|| seg_lower.strip_suffix(".js"))
.or_else(|| seg_lower.strip_suffix(".jsx"))
.unwrap_or(&seg_lower);
seg_clean.len() >= 3 && test_lower.contains(seg_clean)
})
.count();
if overlap >= 2 && best_match.is_none_or(|(_, best)| overlap > best) {
best_match = Some((test, overlap));
}
}
if let Some((test, _)) = best_match {
return Some((test, CoverageMatchTier::Low));
}
}
None
}
fn find_test_by_import<'a>(
source: &FileChange,
test_files: &[&'a FileChange],
test_contents: &HashMap<String, String>,
) -> Option<(&'a FileChange, CoverageMatchTier)> {
let src_stem = Path::new(&source.path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if src_stem.is_empty() {
return None;
}
let p = source.path.as_str();
let module_path = p
.strip_suffix(".py")
.or_else(|| p.strip_suffix(".ts"))
.or_else(|| p.strip_suffix(".tsx"))
.or_else(|| p.strip_suffix(".js"))
.or_else(|| p.strip_suffix(".jsx"))
.or_else(|| p.strip_suffix(".rs"))
.unwrap_or(p);
let module_dot = module_path.replace('/', ".");
let module_slash = module_path;
let needles: Vec<&str> = vec![&module_dot, module_slash, src_stem]
.into_iter()
.filter(|s| s.len() >= 3) .collect();
if needles.is_empty() {
return None;
}
for test in test_files {
let content = match test_contents.get(&test.path) {
Some(c) => c,
None => continue,
};
for line in content.lines() {
let trimmed = line.trim();
let is_import = trimmed.starts_with("import ")
|| trimmed.starts_with("from ")
|| trimmed.starts_with("use ")
|| trimmed.starts_with("require(")
|| trimmed.contains("import(")
|| trimmed.contains("require(");
if !is_import {
continue;
}
if needles
.iter()
.any(|needle| contains_token_match(trimmed, needle))
{
return Some((test, CoverageMatchTier::Medium));
}
}
}
None
}
fn find_ghost_test_on_disk(source_path: &str, repo_root: &Path) -> Option<String> {
let path = Path::new(source_path);
let stem = path.file_stem()?.to_str()?;
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
let parent = path.parent().and_then(|p| p.to_str()).unwrap_or("");
let mut candidates = Vec::new();
if ext == "rs" {
if !parent.is_empty() {
candidates.push(format!("{}/{}_test.rs", parent, stem));
candidates.push(format!("{}/test_{}.rs", parent, stem));
candidates.push(format!("{}/tests/{}.rs", parent, stem));
let tests_dir = parent.replace("src", "tests");
if tests_dir != parent {
candidates.push(format!("{}/{}.rs", tests_dir, stem));
}
}
candidates.push(format!("tests/{}.rs", stem));
} else if matches!(ext, "ts" | "tsx" | "js" | "jsx" | "py") {
let e = ext;
if !parent.is_empty() {
candidates.push(format!("{}/{}.test.{}", parent, stem, e));
candidates.push(format!("{}/{}.spec.{}", parent, stem, e));
candidates.push(format!("{}/__tests__/{}.{}", parent, stem, e));
candidates.push(format!("{}/test_{}.{}", parent, stem, e));
candidates.push(format!("{}/{}_test.{}", parent, stem, e));
}
candidates.push(format!("tests/test_{}.{}", stem, e));
candidates.push(format!("tests/{}_test.{}", stem, e));
}
candidates
.into_iter()
.find(|cand| repo_root.join(cand).exists())
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::{
make_diff_with_ids, make_test_repo, mock_diff, mock_file_change,
};
use super::*;
use crate::git::FileStatus;
use tempfile::TempDir;
#[test]
fn coverage_delta_identifies_uncovered() {
let tmp = TempDir::new().unwrap();
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("src/utils.rs", FileStatus::Added, 20, 0),
mock_file_change("tests/lib_test.rs", FileStatus::Modified, 5, 2),
]);
let signal = compute_coverage_signal(&[diff], None, None);
generate_coverage_delta(tmp.path(), &signal).unwrap();
let content = fs::read_to_string(tmp.path().join("coverage-delta.txt")).unwrap();
assert!(content.contains("src/utils.rs"));
assert!(content.contains("NO_TEST_CHANGE"));
assert!(content.contains("lib_test.rs"));
assert!(content.contains("HAS_TEST_CHANGE"));
assert!(content.contains("1/2"));
assert!(content.contains("50%"));
assert!(content.contains("heuristic"));
assert!(content.contains("#[cfg(test)]") || content.contains("inline"));
assert!(content.contains("# Confidence: medium"));
}
#[test]
fn coverage_confidence_medium_when_rust_uncovered() {
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("src/utils.rs", FileStatus::Added, 20, 0),
]);
let signal = compute_coverage_signal(&[diff], None, None);
assert_eq!(signal.confidence, "medium");
assert!(signal.rust_uncovered_count > 0);
}
#[test]
fn coverage_counts_inline_rust_test_modules_as_test_signal() {
let old_content = "pub fn value() -> u32 { 1 }\n";
let new_content = "pub fn value() -> u32 { 2 }\n\n\
#[cfg(test)]\n\
mod tests {\n\
#[test]\n\
fn value_works() { assert_eq!(super::value(), 2); }\n\
}\n";
let files = &[("src/lib.rs", old_content, new_content)];
let (_tmp, repo, base_id, target_id) = make_test_repo(files);
let diff = make_diff_with_ids(
base_id,
target_id,
vec![mock_file_change("src/lib.rs", FileStatus::Modified, 10, 1)],
);
let signal = compute_coverage_signal(&[diff], None, Some(&repo));
assert_eq!(signal.covered_count, 1);
assert_eq!(signal.coverage_pct, 100);
assert!(signal.uncovered_files.is_empty());
assert_eq!(
signal.covered_files[0],
(
"src/lib.rs".to_string(),
"(inline #[cfg(test)])".to_string(),
CoverageMatchTier::High,
)
);
}
#[test]
fn coverage_inline_cfg_test_without_test_fn_not_counted() {
let old_content = "pub fn value() -> u32 { 1 }\n";
let new_content = "pub fn value() -> u32 { 2 }\n\n\
#[cfg(test)]\n\
use std::fmt;\n";
let files = &[("src/lib.rs", old_content, new_content)];
let (_tmp, repo, base_id, target_id) = make_test_repo(files);
let diff = make_diff_with_ids(
base_id,
target_id,
vec![mock_file_change("src/lib.rs", FileStatus::Modified, 2, 1)],
);
let signal = compute_coverage_signal(&[diff], None, Some(&repo));
assert_eq!(signal.uncovered_files, vec!["src/lib.rs"]);
assert!(signal.coverage_pct < 100);
}
#[test]
fn coverage_confidence_high_when_all_covered() {
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("tests/lib_test.rs", FileStatus::Modified, 5, 2),
]);
let signal = compute_coverage_signal(&[diff], None, None);
assert_eq!(signal.confidence, "high");
}
#[test]
fn coverage_delta_excludes_non_code_files_from_source_count() {
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("tests/lib_test.rs", FileStatus::Modified, 5, 2),
mock_file_change(".gitignore", FileStatus::Modified, 1, 0),
mock_file_change("Makefile", FileStatus::Added, 12, 0),
mock_file_change("tools/setup_hooks.sh", FileStatus::Added, 20, 0),
mock_file_change("README.md", FileStatus::Modified, 8, 1),
]);
let signal = compute_coverage_signal(&[diff], None, None);
assert_eq!(signal.total_source_files, 1);
assert_eq!(signal.covered_count, 1);
assert_eq!(signal.non_code_count, 4);
assert_eq!(signal.coverage_pct, 100);
}
#[test]
fn coverage_signal_only_warns_about_inline_rust_tests_when_rust_files_are_uncovered() {
let uncovered = compute_coverage_signal(
&[mock_diff(vec![mock_file_change(
"src/lib.rs",
FileStatus::Modified,
10,
2,
)])],
None,
None,
);
assert_eq!(uncovered.confidence, "medium");
assert!(
uncovered
.limitations
.iter()
.any(|item| item.contains("Inline #[cfg(test)] modules"))
);
let covered = compute_coverage_signal(
&[mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 2),
mock_file_change("tests/lib_test.rs", FileStatus::Modified, 5, 1),
])],
None,
None,
);
assert_eq!(covered.confidence, "high");
assert!(
!covered
.limitations
.iter()
.any(|item| item.contains("Inline #[cfg(test)] modules"))
);
}
#[test]
fn coverage_signal_import_recovery_respects_underscore_identifier_boundaries() {
let (_tmp, repo, base_id, target_id) = make_test_repo(&[
(
"src/api.rs",
"pub fn api() {}\n",
"pub fn api() -> bool { true }\n",
),
(
"tests/client.rs",
"use api_client::Client;\n\n#[test]\nfn works() {}\n",
"use api_client::Client;\n\n#[test]\nfn works() {\n let _ = Client::new();\n}\n",
),
]);
let diff = make_diff_with_ids(
base_id,
target_id,
vec![
mock_file_change("src/api.rs", FileStatus::Modified, 1, 1),
mock_file_change("tests/client.rs", FileStatus::Modified, 2, 1),
],
);
let signal = compute_coverage_signal(&[diff], None, Some(&repo));
assert_eq!(signal.covered_count, 0);
assert_eq!(signal.uncovered_files, vec!["src/api.rs"]);
}
#[test]
fn coverage_inline_cfg_test_counts_as_covered() {
let new = "pub fn add(a: i32, b: i32) -> i32 { a + b }\n\
#[cfg(test)]\n\
mod tests {\n #[test]\n fn t() { assert_eq!(super::add(1, 1), 2); }\n}\n";
let (_tmp, repo, base_id, target_id) = make_test_repo(&[(
"src/calc.rs",
"pub fn add(a: i32, b: i32) -> i32 { a + b }\n",
new,
)]);
let diff = make_diff_with_ids(
base_id,
target_id,
vec![mock_file_change("src/calc.rs", FileStatus::Modified, 5, 0)],
);
let signal = compute_coverage_signal(&[diff], None, Some(&repo));
assert!(signal.inline_tests_checked);
assert_eq!(signal.inline_tested_count, 1);
assert!(
signal.uncovered_files.is_empty(),
"inline-tested file must not be counted as uncovered"
);
assert_eq!(signal.coverage_pct, 100);
assert!(
signal
.covered_files
.iter()
.any(|(s, t, _)| s == "src/calc.rs" && t.contains("inline")),
"inline-tested file should appear as covered with an inline label"
);
}
#[test]
fn coverage_inline_cfg_test_helper_only_not_counted() {
let new = "pub fn add(a: i32, b: i32) -> i32 { a + b }\n\
#[cfg(test)]\n\
use std::fmt;\n";
let (_tmp, repo, base_id, target_id) = make_test_repo(&[(
"src/calc.rs",
"pub fn add(a: i32, b: i32) -> i32 { a + b }\n",
new,
)]);
let diff = make_diff_with_ids(
base_id,
target_id,
vec![mock_file_change("src/calc.rs", FileStatus::Modified, 2, 0)],
);
let signal = compute_coverage_signal(&[diff], None, Some(&repo));
assert!(signal.inline_tests_checked);
assert_eq!(
signal.inline_tested_count, 0,
"a cfg(test) import without #[test] must not count as inline-tested"
);
assert_eq!(signal.uncovered_files, vec!["src/calc.rs"]);
assert!(signal.coverage_pct < 100);
}
#[test]
fn coverage_delta_empty() {
let tmp = TempDir::new().unwrap();
let diff = mock_diff(vec![]);
let signal = compute_coverage_signal(&[diff], None, None);
generate_coverage_delta(tmp.path(), &signal).unwrap();
let content = fs::read_to_string(tmp.path().join("coverage-delta.txt")).unwrap();
assert!(content.contains("0/0"));
assert!(content.contains("100%"));
}
#[test]
fn coverage_text_and_struct_are_consistent() {
let diff = mock_diff(vec![
mock_file_change("src/lib.rs", FileStatus::Modified, 10, 5),
mock_file_change("src/utils.rs", FileStatus::Added, 20, 0),
mock_file_change("tests/lib_test.rs", FileStatus::Modified, 5, 2),
]);
let signal = compute_coverage_signal(&[diff], None, None);
let delta = CoverageDelta::from_signal(&signal);
let tmp = TempDir::new().unwrap();
generate_coverage_delta(tmp.path(), &signal).unwrap();
let text = fs::read_to_string(tmp.path().join("coverage-delta.txt")).unwrap();
assert!(
text.contains(&format!("{}/{}", delta.covered_count, delta.total_source)),
"Text artifact must show {}/{} but got:\n{}",
delta.covered_count,
delta.total_source,
text
);
assert!(
text.contains(&format!("{}%", delta.pct)),
"Text artifact must show {}% but got:\n{}",
delta.pct,
text
);
assert!(
text.contains(&format!("Confidence: {}", signal.confidence)),
"Text artifact must show Confidence: {} but got:\n{}",
signal.confidence,
text
);
assert_eq!(delta.covered_count, signal.covered_count);
assert_eq!(delta.total_source, signal.total_source_files);
assert_eq!(delta.pct, signal.coverage_pct);
}
#[test]
fn coverage_ghost_test_deleted_source_test_remains() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("src/foo")).unwrap();
fs::write(tmp.path().join("src/foo/bar_test.rs"), "test code").unwrap();
let diff = mock_diff(vec![mock_file_change(
"src/foo/bar.rs",
FileStatus::Deleted,
10,
5,
)]);
let signal = compute_coverage_signal(&[diff], Some(tmp.path()), None);
assert_eq!(signal.ghost_tests.len(), 1);
assert_eq!(signal.ghost_tests[0].0, "src/foo/bar.rs");
assert_eq!(signal.ghost_tests[0].1, "src/foo/bar_test.rs");
generate_coverage_delta(tmp.path(), &signal).unwrap();
let content = fs::read_to_string(tmp.path().join("coverage-delta.txt")).unwrap();
assert!(content.contains("ORPHANED_TEST_DETECTED"));
assert!(content.contains("src/foo/bar.rs -> likely ghost test: src/foo/bar_test.rs"));
}
#[test]
fn coverage_ghost_test_deleted_source_and_test_together() {
let tmp = TempDir::new().unwrap();
let diff = mock_diff(vec![
mock_file_change("src/api.rs", FileStatus::Deleted, 10, 5),
mock_file_change("src/api_test.rs", FileStatus::Deleted, 10, 5),
]);
let signal = compute_coverage_signal(&[diff], Some(tmp.path()), None);
assert_eq!(signal.ghost_tests.len(), 0);
}
#[test]
fn coverage_ghost_test_deleted_source_no_test_ever() {
let tmp = TempDir::new().unwrap();
let diff = mock_diff(vec![mock_file_change(
"src/main.rs",
FileStatus::Deleted,
10,
5,
)]);
let signal = compute_coverage_signal(&[diff], Some(tmp.path()), None);
assert_eq!(signal.ghost_tests.len(), 0);
let delta = CoverageDelta::from_signal(&signal);
assert_eq!(delta.ghost_tests.len(), 0);
}
#[test]
fn coverage_ghost_test_multiple_deleted() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("src")).unwrap();
fs::write(tmp.path().join("src/a_test.rs"), "test code").unwrap();
fs::write(tmp.path().join("src/b_test.rs"), "test code").unwrap();
let diff = mock_diff(vec![
mock_file_change("src/a.rs", FileStatus::Deleted, 10, 5),
mock_file_change("src/b.rs", FileStatus::Deleted, 10, 5),
mock_file_change("src/c.rs", FileStatus::Deleted, 10, 5),
]);
let signal = compute_coverage_signal(&[diff], Some(tmp.path()), None);
assert_eq!(signal.ghost_tests.len(), 2);
}
}