use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use crate::core::{CoverageFormat, CoverageReport};
#[derive(Debug, Clone)]
pub struct CoverageConfig {
pub enabled: bool,
pub format: CoverageFormat,
pub output_dir: PathBuf,
pub min_threshold: Option<f64>,
pub open_report: bool,
pub extra_test_args: Vec<String>,
pub sample_interval_ms: u64,
}
impl Default for CoverageConfig {
fn default() -> Self {
CoverageConfig {
enabled: false,
format: CoverageFormat::Summary,
output_dir: PathBuf::from("target/coverage"),
min_threshold: None,
open_report: false,
extra_test_args: Vec::new(),
sample_interval_ms: 5,
}
}
}
pub struct CoverageCollector {
config: CoverageConfig,
}
impl CoverageCollector {
pub fn new(config: CoverageConfig) -> Self {
CoverageCollector { config }
}
pub fn collect(&self) -> Result<CoverageReport, String> {
if self.has_cargo_llvm_cov() {
return self.run_via_cargo_llvm_cov();
}
if self.has_llvm_tools() {
return self.run_via_llvm_tools();
}
if self.self_contained_profraw() {
return self.run_via_raw_parser();
}
self.run_via_sampler()
}
fn has_cargo_llvm_cov(&self) -> bool {
Command::new("cargo")
.args(["llvm-cov", "--help"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn has_llvm_tools(&self) -> bool {
self.find_tool("llvm-profdata").is_some()
&& self.find_tool("llvm-cov").is_some()
}
fn has_addr2line(&self) -> bool {
self.find_tool("addr2line").is_some()
}
fn find_tool(&self, name: &str) -> Option<PathBuf> {
if let Ok(path) = which(name) {
return Some(path);
}
if let Ok(home) = std::env::var("RUSTUP_HOME") {
if let Ok(output) = Command::new("rustup").args(["default"]).output() {
if output.status.success() {
let tc = String::from_utf8_lossy(&output.stdout).trim().to_owned();
for sub in ["lib/rustlib/x86_64-unknown-linux-gnu/bin", "bin"] {
let p = PathBuf::from(&home).join("toolchains").join(&tc).join(sub).join(name);
if p.exists() {
return Some(p);
}
}
}
}
}
None
}
fn run_via_cargo_llvm_cov(&self) -> Result<CoverageReport, String> {
let out_dir = &self.config.output_dir;
let format_flag = match self.config.format {
CoverageFormat::Summary => "--summary-only",
CoverageFormat::Html => "--html",
CoverageFormat::Lcov => "--lcov",
CoverageFormat::Json => "--json",
CoverageFormat::Cobertura => "--cobertura",
};
let mut cmd = Command::new("cargo");
cmd.args(["llvm-cov", "--all-targets", format_flag]);
if !matches!(self.config.format, CoverageFormat::Summary) {
cmd.arg("--output-dir").arg(out_dir);
}
if !self.config.extra_test_args.is_empty() {
cmd.arg("--").args(&self.config.extra_test_args);
}
let status = cmd.status().map_err(|e| format!("cargo-llvm-cov: {e}"))?;
if !status.success() {
return Err("cargo-llvm-cov returned non-zero exit".into());
}
let report_path = if !matches!(self.config.format, CoverageFormat::Summary) {
Some(out_dir.join(crate::coverage_raw::report_filename(self.config.format)))
} else {
None
};
let (line, func, region) = self.parse_llvm_cov_summary()?;
self.check_threshold_and_open(CoverageReport {
line_coverage: line,
function_coverage: func,
region_coverage: region,
format: self.config.format,
report_path,
})
}
fn parse_llvm_cov_summary(&self) -> Result<(f64, f64, f64), String> {
let output = Command::new("cargo")
.args(["llvm-cov", "--summary-only", "--all-targets"])
.output()
.map_err(|e| format!("cargo-llvm-cov summary: {e}"))?;
if !output.status.success() {
return Ok((0.0, 0.0, 0.0));
}
Ok(parse_coverage_percentages(&String::from_utf8_lossy(&output.stdout)))
}
fn run_via_llvm_tools(&self) -> Result<CoverageReport, String> {
let out_dir = &self.config.output_dir;
let profraw_dir = out_dir.join("profraw");
for d in [&profraw_dir, out_dir] {
std::fs::create_dir_all(d).map_err(|e| format!("mkdir {d:?}: {e}"))?;
}
let llvm_profile = profraw_dir.join("default_%p_%m.profraw");
let build = self.run_cargo_test_no_run(Some(&llvm_profile))?;
let binaries = crate::coverage_raw::parse_test_binaries(&build.stdout);
if binaries.is_empty() {
return Err("no test binaries produced".into());
}
for bin in &binaries {
let s = Command::new(bin)
.env("LLVM_PROFILE_FILE", llvm_profile.to_str().unwrap())
.args(&self.config.extra_test_args)
.status()
.map_err(|e| format!("run {bin:?}: {e}"))?;
if !s.success() {
eprintln!("warning: {bin:?} exited non-zero");
}
}
let merged = out_dir.join("merged.profdata");
let profraws = glob_dir(&profraw_dir, "*.profraw")?;
if profraws.is_empty() {
return Err("no .profraw files produced".into());
}
let pdata = self.find_tool("llvm-profdata").unwrap();
let mut mc = Command::new(&pdata);
mc.args(["merge", "-sparse"]);
for f in &profraws {
mc.arg(f);
}
mc.arg("-o").arg(&merged);
if !mc.status().map_err(|e| format!("llvm-profdata: {e}"))?.success() {
return Err("llvm-profdata merge failed".into());
}
let cov = self.find_tool("llvm-cov").unwrap();
let report = self.llvm_cov_report(&cov, &merged, &binaries)?;
self.check_threshold_and_open(report)
}
fn llvm_cov_report(&self, cov: &Path, profdata: &Path, bins: &[PathBuf]) -> Result<CoverageReport, String> {
match self.config.format {
CoverageFormat::Summary => {
let (l, f, r) = self.llvm_summary(cov, profdata, bins)?;
Ok(CoverageReport { line_coverage: l, function_coverage: f, region_coverage: r, format: CoverageFormat::Summary, report_path: None })
}
_ => {
let (l, f, r) = self.llvm_summary(cov, profdata, bins)?;
let filename = crate::coverage_raw::report_filename(self.config.format);
let path = self.config.output_dir.join(&filename);
let fmt = match self.config.format {
CoverageFormat::Html => "html",
CoverageFormat::Lcov => "lcov",
CoverageFormat::Json => "text",
_ => return Err("format requires cargo-llvm-cov".into()),
};
let mut cmd = Command::new(cov);
cmd.args(["show", "--format", fmt])
.arg("--instr-profile").arg(profdata);
for b in bins { cmd.arg("--object").arg(b); }
let out = cmd.stdout(Stdio::piped()).stderr(Stdio::inherit())
.output().map_err(|e| format!("llvm-cov: {e}"))?;
std::fs::write(&path, &out.stdout)
.map_err(|e| format!("write {path:?}: {e}"))?;
Ok(CoverageReport { line_coverage: l, function_coverage: f, region_coverage: r, format: self.config.format, report_path: Some(path) })
}
}
}
fn llvm_summary(&self, cov: &Path, profdata: &Path, bins: &[PathBuf]) -> Result<(f64, f64, f64), String> {
let mut cmd = Command::new(cov);
cmd.args(["report", "--summary-only", "--use-color=false"])
.arg("--instr-profile").arg(profdata);
for b in bins { cmd.arg("--object").arg(b); }
let out = cmd.stdout(Stdio::piped()).stderr(Stdio::inherit())
.output().map_err(|e| format!("llvm-cov report: {e}"))?;
Ok(parse_coverage_percentages(&String::from_utf8_lossy(&out.stdout)))
}
#[cfg(target_os = "linux")]
fn run_via_sampler(&self) -> Result<CoverageReport, String> {
if !self.has_addr2line() {
return Err(
"built-in sampler requires `addr2line` (install binutils).\n\
Or install one of:\n \
cargo install cargo-llvm-cov\n \
rustup component add llvm-tools-preview"
.into()
);
}
let out_dir = &self.config.output_dir;
std::fs::create_dir_all(out_dir).map_err(|e| format!("mkdir {out_dir:?}: {e}"))?;
let build = self.run_cargo_test_no_run(None)?;
let binaries = crate::coverage_raw::parse_test_binaries(&build.stdout);
let binary = binaries.first().ok_or("no test binary produced")?;
if !binary.exists() {
return Err(format!("test binary not found: {binary:?}"));
}
let samples = sample_ips(binary, self.config.sample_interval_ms, &self.config.extra_test_args)?;
if samples.is_empty() {
return Err("no instruction pointer samples collected".into());
}
let locations = resolve_with_addr2line(binary, &samples)?;
let total_source = count_source_lines("src")?;
let unique_hit: HashSet<(String, u64)> = locations.into_iter().collect();
let hit_count = unique_hit.len();
let line_cov = if total_source > 0 {
(hit_count as f64 / total_source as f64 * 100.0).min(100.0)
} else {
0.0
};
let function_cov = line_cov;
let report = CoverageReport {
line_coverage: line_cov,
function_coverage: function_cov,
region_coverage: line_cov,
format: self.config.format,
report_path: None,
};
println!(
"\n📊 Built-in sampler coverage (statistical):\n \
Lines hit: {hit_count} / {total_source} ({line_cov:.1}%)\n \
Samples: {} (interval: {}ms)\n",
samples.len(),
self.config.sample_interval_ms,
);
self.check_threshold_and_open(report)
}
#[cfg(not(target_os = "linux"))]
fn run_via_sampler(&self) -> Result<CoverageReport, String> {
Err("built-in sampler is only available on Linux.\n\
Install one of:\n \
cargo install cargo-llvm-cov\n \
rustup component add llvm-tools-preview"
.into())
}
fn self_contained_profraw(&self) -> bool {
let rustc_version = || -> Option<(u32, u32)> {
let output = Command::new("rustc").arg("--version").output().ok()?;
let s = String::from_utf8_lossy(&output.stdout);
let v = s.split_whitespace().nth(1)?;
let parts: Vec<&str> = v.split('.').collect();
let major: u32 = parts.first()?.parse().ok()?;
let minor: u32 = parts.get(1)?.parse().ok()?;
Some((major, minor))
};
rustc_version()
.map(|(major, minor)| major >= 1 && minor >= 96)
.unwrap_or(false)
}
fn run_via_raw_parser(&self) -> Result<CoverageReport, String> {
let runner = crate::coverage_raw::RawCoverageRunner {
output_dir: self.config.output_dir.clone(),
extra_test_args: self.config.extra_test_args.clone(),
};
runner.run(self.config.format)
}
fn run_cargo_test_no_run(&self, llvm_profile: Option<&Path>) -> Result<Output, String> {
let mut cmd = Command::new("cargo");
cmd.args(["test", "--no-run", "--message-format=json"])
.stdout(Stdio::piped())
.stderr(Stdio::inherit());
if let Some(prof) = llvm_profile {
cmd.env("CARGO_INCREMENTAL", "0");
cmd.env("RUSTFLAGS", "-Cinstrument-coverage");
cmd.env("LLVM_PROFILE_FILE", prof.to_str().unwrap());
}
if !self.config.extra_test_args.is_empty() {
cmd.arg("--").args(&self.config.extra_test_args);
}
cmd.output().map_err(|e| format!("cargo test --no-run: {e}"))
}
fn check_threshold_and_open(&self, report: CoverageReport) -> Result<CoverageReport, String> {
if let Some(threshold) = self.config.min_threshold
&& report.line_coverage < threshold
{
return Err(format!(
"coverage {:.1}% is below minimum {threshold:.1}%",
report.line_coverage,
));
}
if self.config.open_report {
if let Some(ref path) = report.report_path {
open_in_browser(path);
}
}
Ok(report)
}
}
#[cfg(target_os = "linux")]
fn sample_ips(binary: &Path, interval_ms: u64, _extra_args: &[String]) -> Result<Vec<u64>, String> {
use std::ffi::CString;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let bin_cstr = CString::new(binary.to_str().ok_or("invalid binary path")?)
.map_err(|_| "binary path contains null byte")?;
let child_pid = unsafe { libc::fork() };
if child_pid == -1 {
return Err("fork failed".into());
}
if child_pid == 0 {
unsafe {
libc::ptrace(libc::PTRACE_TRACEME, 0, std::ptr::null_mut::<libc::c_void>(), std::ptr::null_mut::<libc::c_void>());
libc::raise(libc::SIGSTOP);
}
let args: Vec<CString> = std::iter::once(bin_cstr.clone())
.chain(_extra_args.iter().map(|a| CString::new(a.as_bytes()).unwrap()))
.collect();
let mut argv: Vec<*const libc::c_char> = args.iter().map(|a| a.as_ptr()).collect();
argv.push(std::ptr::null());
unsafe {
libc::execvp(bin_cstr.as_ptr(), argv.as_ptr());
libc::_exit(1);
}
}
let mut samples: Vec<u64> = Vec::new();
let running = Arc::new(AtomicBool::new(true));
let r = running.clone();
let timer = std::thread::spawn(move || {
while r.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(interval_ms));
if !r.load(Ordering::SeqCst) {
break;
}
unsafe {
libc::kill(child_pid, libc::SIGSTOP);
}
}
});
let mut status: libc::c_int = 0;
unsafe {
libc::waitpid(child_pid, &mut status as *mut libc::c_int, 0);
}
let max_samples = 200_000u64;
let mut sample_count = 0u64;
loop {
if sample_count >= max_samples {
break;
}
unsafe {
libc::waitpid(child_pid, &mut status as *mut libc::c_int, 0);
}
if libc::WIFEXITED(status) || libc::WIFSIGNALED(status) {
break;
}
let stop_signal = if libc::WIFSTOPPED(status) {
libc::WSTOPSIG(status)
} else {
0
};
if stop_signal == libc::SIGSTOP {
let mut regs: libc::user_regs_struct = unsafe { std::mem::zeroed() };
let mut iov = libc::iovec {
iov_base: &mut regs as *mut _ as *mut libc::c_void,
iov_len: std::mem::size_of::<libc::user_regs_struct>(),
};
let res = unsafe {
libc::ptrace(
libc::PTRACE_GETREGSET as libc::c_uint,
child_pid,
libc::NT_PRSTATUS as *mut libc::c_void,
&mut iov as *mut libc::iovec as *mut libc::c_void,
)
};
if res == 0 {
#[cfg(target_arch = "x86_64")]
let ip = regs.rip;
#[cfg(target_arch = "aarch64")]
let ip = regs.pc;
#[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
let ip = 0u64;
if ip > 0 {
samples.push(ip);
sample_count += 1;
}
}
unsafe {
libc::ptrace(
libc::PTRACE_CONT as libc::c_uint,
child_pid,
std::ptr::null_mut::<libc::c_void>(),
libc::SIGCONT as *mut libc::c_void,
);
}
} else if stop_signal > 0 {
unsafe {
libc::ptrace(
libc::PTRACE_CONT as libc::c_uint,
child_pid,
std::ptr::null_mut::<libc::c_void>(),
stop_signal as *mut libc::c_void,
);
}
} else {
unsafe {
libc::ptrace(
libc::PTRACE_CONT as libc::c_uint,
child_pid,
std::ptr::null_mut::<libc::c_void>(),
std::ptr::null_mut::<libc::c_void>(),
);
}
}
}
running.store(false, Ordering::SeqCst);
let _ = timer.join();
unsafe {
libc::kill(child_pid, libc::SIGKILL);
libc::waitpid(child_pid, &mut status as *mut libc::c_int, 0);
}
Ok(samples)
}
#[cfg(target_os = "linux")]
fn resolve_with_addr2line(binary: &Path, ips: &[u64]) -> Result<Vec<(String, u64)>, String> {
let unique: Vec<u64> = {
let mut v: Vec<u64> = ips.to_vec();
v.sort();
v.dedup();
v
};
if unique.is_empty() {
return Ok(Vec::new());
}
let mut cmd = Command::new("addr2line");
cmd.arg("-e").arg(binary);
cmd.arg("-f").arg("-a"); for ip in &unique {
cmd.arg(format!("0x{ip:x}"));
}
let output = cmd.stdout(Stdio::piped()).stderr(Stdio::inherit())
.output()
.map_err(|e| format!("addr2line: {e}"))?;
if !output.status.success() {
return Err("addr2line returned non-zero exit".into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let mut locations = Vec::new();
let mut lines = stdout.lines();
while let Some(_addr_line) = lines.next() {
let _func = lines.next().unwrap_or("??");
let loc = lines.next().unwrap_or("??:0");
if loc != "??:0" && !loc.contains('?') {
if let Some((file, line_str)) = loc.rsplit_once(':') {
if let Ok(line_num) = line_str.parse::<u64>() {
locations.push((file.to_owned(), line_num));
}
}
}
}
Ok(locations)
}
#[cfg(target_os = "linux")]
fn count_source_lines(dir: &str) -> Result<usize, String> {
let mut total = 0usize;
let mut dirs = vec![dir.to_owned()];
while let Some(current) = dirs.pop() {
let entries = match std::fs::read_dir(¤t) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
let path = entry.path();
if path.is_dir() {
let name = path.file_name().and_then(OsStr::to_str).unwrap_or("");
if !name.starts_with('.') && name != "target" {
dirs.push(path.to_str().unwrap_or("").to_owned());
}
} else if path.extension().map_or(false, |e| e == "rs") {
if let Ok(content) = std::fs::read_to_string(&path) {
for line in content.lines() {
let t = line.trim();
if !t.is_empty() && !t.starts_with("//") {
total += 1;
}
}
}
}
}
}
Ok(total)
}
fn which(name: &str) -> Result<PathBuf, ()> {
let paths = std::env::var_os("PATH").ok_or(())?;
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(name);
if candidate.exists() {
return Ok(candidate);
}
if cfg!(windows) {
let candidate_exe = dir.join(format!("{name}.exe"));
if candidate_exe.exists() {
return Ok(candidate_exe);
}
}
}
Err(())
}
fn glob_dir(dir: &Path, pattern: &str) -> Result<Vec<PathBuf>, String> {
let mut results = Vec::new();
let entries = std::fs::read_dir(dir).map_err(|e| format!("read_dir {dir:?}: {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("entry: {e}"))?;
let path = entry.path();
if let Some(name) = path.file_name().and_then(OsStr::to_str)
&& name.contains(pattern.trim_end_matches('*'))
{
results.push(path);
}
}
Ok(results)
}
fn parse_coverage_percentages(summary: &str) -> (f64, f64, f64) {
let mut line = 0.0;
let mut func = 0.0;
let mut region = 0.0;
for line_text in summary.lines() {
let t = line_text.trim();
if t.starts_with("Lines:") || t.starts_with(" Lines:") {
line = extract_pct(t);
} else if t.starts_with("Functions:") || t.starts_with(" Functions:") {
func = extract_pct(t);
} else if t.starts_with("Regions:") || t.starts_with(" Regions:") {
region = extract_pct(t);
}
}
(line, func, region)
}
fn extract_pct(s: &str) -> f64 {
if let Some(start) = s.find(|c: char| c.is_ascii_digit()) {
let rest = &s[start..];
if let Some(end) = rest.find('%')
&& let Ok(val) = rest[..end].parse::<f64>()
{
return val;
}
}
0.0
}
#[cfg(target_os = "linux")]
fn open_in_browser(path: &Path) {
let _ = Command::new("xdg-open").arg(path).status();
}
#[cfg(target_os = "macos")]
fn open_in_browser(path: &Path) {
let _ = Command::new("open").arg(path).status();
}
#[cfg(target_os = "windows")]
fn open_in_browser(path: &Path) {
let _ = Command::new("cmd").args(["/c", "start", ""]).arg(path).status();
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn open_in_browser(_path: &Path) {
eprintln!("--open not supported on this platform");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_coverage_all_fields() {
let summary = "Lines: 45.6%\nFunctions: 60.0%\nRegions: 45.6%\n";
let (l, f, r) = parse_coverage_percentages(summary);
assert!((l - 45.6).abs() < 0.01);
assert!((f - 60.0).abs() < 0.01);
assert!((r - 45.6).abs() < 0.01);
}
#[test]
fn parse_coverage_partial() {
let summary = "Lines: 100.0%\n";
let (l, f, r) = parse_coverage_percentages(summary);
assert!((l - 100.0).abs() < 0.01);
assert_eq!(f, 0.0);
assert_eq!(r, 0.0);
}
#[test]
fn parse_coverage_empty() {
assert_eq!(parse_coverage_percentages(""), (0.0, 0.0, 0.0));
}
#[test]
fn parse_coverage_indented() {
let summary = " Lines: 33.3%\n Functions: 50.0%\n";
let (l, f, _) = parse_coverage_percentages(summary);
assert!((l - 33.3).abs() < 0.01);
assert!((f - 50.0).abs() < 0.01);
}
#[test]
fn extract_pct_normal() {
assert!((extract_pct("Lines: 75.5%") - 75.5).abs() < 0.01);
}
#[test]
fn extract_pct_no_percent() {
assert_eq!(extract_pct("no percentage here"), 0.0);
}
#[test]
fn extract_pct_only_number() {
assert!((extract_pct("42%") - 42.0).abs() < 0.01);
}
#[test]
fn extract_pct_no_digits() {
assert_eq!(extract_pct("No digits here %"), 0.0);
}
#[test]
fn which_returns_none_for_nonexistent() {
let result = which("this_tool_definitely_does_not_exist_xyz");
assert!(result.is_err());
}
#[test]
fn coverage_config_default() {
let cfg = CoverageConfig::default();
assert!(!cfg.enabled);
assert_eq!(cfg.format, CoverageFormat::Summary);
assert_eq!(cfg.output_dir, PathBuf::from("target/coverage"));
assert!(cfg.min_threshold.is_none());
assert!(!cfg.open_report);
assert!(cfg.extra_test_args.is_empty());
}
#[test]
fn coverage_config_custom() {
let cfg = CoverageConfig {
enabled: true,
format: CoverageFormat::Html,
output_dir: PathBuf::from("custom"),
min_threshold: Some(80.0),
open_report: true,
extra_test_args: vec!["--feature".into()],
sample_interval_ms: 10,
};
assert!(cfg.enabled);
assert_eq!(cfg.format, CoverageFormat::Html);
assert_eq!(cfg.min_threshold, Some(80.0));
}
#[test]
fn coverage_report_struct() {
let report = CoverageReport {
line_coverage: 50.0,
function_coverage: 60.0,
region_coverage: 50.0,
format: CoverageFormat::Summary,
report_path: None,
};
assert!((report.line_coverage - 50.0).abs() < 0.01);
assert!(report.report_path.is_none());
}
#[test]
fn coverage_collector_new_default() {
let cfg = CoverageConfig::default();
let collector = CoverageCollector::new(cfg);
assert!(!collector.config.enabled);
}
#[test]
fn coverage_config_sample_interval() {
let cfg = CoverageConfig {
sample_interval_ms: 100,
..CoverageConfig::default()
};
assert_eq!(cfg.sample_interval_ms, 100);
}
#[test]
fn which_finds_sh() {
let result = which("sh");
assert!(result.is_ok(), "sh should be in PATH");
let path = result.unwrap();
assert!(path.exists());
}
#[test]
fn which_checks_absolute_paths() {
let result = which("/bin/sh");
if let Ok(path) = result {
assert!(path.exists());
}
}
#[test]
fn glob_dir_finds_profraw() {
let tmp = std::env::temp_dir().join("rvtest_glob_test");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
std::fs::write(tmp.join("test.profraw"), "").unwrap();
std::fs::write(tmp.join("other.txt"), "").unwrap();
let files = glob_dir(&tmp, "profraw").unwrap();
assert_eq!(files.len(), 1, "should find one .profraw file, got {files:?}");
assert!(files[0].to_string_lossy().contains("test.profraw"), "should find test.profraw");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn glob_dir_empty_dir() {
let tmp = std::env::temp_dir().join("rvtest_glob_empty");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
let files = glob_dir(&tmp, "*.profraw").unwrap();
assert!(files.is_empty());
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn glob_dir_nonexistent_dir() {
let tmp = std::env::temp_dir().join("rvtest_glob_nonexistent");
let _ = std::fs::remove_dir_all(&tmp);
let result = glob_dir(&tmp, "*");
assert!(result.is_err());
}
#[test]
fn extract_pct_zero() {
assert!((extract_pct("0%") - 0.0).abs() < 0.01);
}
#[test]
fn extract_pct_trailing_decimal() {
assert!((extract_pct("50.%") - 50.0).abs() < 0.01, "50.% should parse as 50.0");
}
#[test]
fn extract_pct_negative() {
assert!((extract_pct("-10.5%") - 10.5).abs() < 0.01, "should parse 10.5 from -10.5%");
}
#[test]
fn parse_coverage_nonstandard_order() {
let summary = "Regions: 30.0%\nLines: 50.0%\nFunctions: 70.0%\n";
let (l, f, r) = parse_coverage_percentages(summary);
assert!((l - 50.0).abs() < 0.01);
assert!((f - 70.0).abs() < 0.01);
assert!((r - 30.0).abs() < 0.01);
}
#[test]
fn check_threshold_above_min() {
let cfg = CoverageConfig {
min_threshold: Some(50.0),
..CoverageConfig::default()
};
let collector = CoverageCollector::new(cfg);
let report = CoverageReport {
line_coverage: 80.0,
function_coverage: 90.0,
region_coverage: 80.0,
format: CoverageFormat::Summary,
report_path: None,
};
let result = collector.check_threshold_and_open(report);
assert!(result.is_ok(), "above threshold should pass");
}
#[test]
fn check_threshold_below_min() {
let cfg = CoverageConfig {
min_threshold: Some(50.0),
..CoverageConfig::default()
};
let collector = CoverageCollector::new(cfg);
let report = CoverageReport {
line_coverage: 30.0,
function_coverage: 40.0,
region_coverage: 30.0,
format: CoverageFormat::Summary,
report_path: None,
};
let result = collector.check_threshold_and_open(report);
assert!(result.is_err(), "below threshold should fail");
}
#[test]
fn check_threshold_no_min() {
let cfg = CoverageConfig {
min_threshold: None,
..CoverageConfig::default()
};
let collector = CoverageCollector::new(cfg);
let report = CoverageReport {
line_coverage: 10.0,
function_coverage: 10.0,
region_coverage: 10.0,
format: CoverageFormat::Summary,
report_path: None,
};
let result = collector.check_threshold_and_open(report);
assert!(result.is_ok(), "no threshold should always pass");
}
#[test]
fn which_empty_path() {
let original = std::env::var_os("PATH");
unsafe { std::env::set_var("PATH", ""); }
let result = which("anything");
assert!(result.is_err());
if let Some(p) = original {
unsafe { std::env::set_var("PATH", p); }
}
}
}