use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::core::{CoverageFormat, CoverageReport};
const RAW_MAGIC: u64 = 0xff6c70726f667281;
const EXPECTED_VERSION: u64 = 10;
#[repr(C)]
struct RawHeader {
magic: u64,
version: u64,
binary_ids_size: u64,
num_data: u64,
padding_before_counters: u64,
num_counters: u64,
padding_after_counters: u64,
num_bitmap_bytes: u64,
padding_after_bitmap: u64,
names_size: u64,
counters_delta: u64,
bitmap_delta: u64,
names_delta: u64,
num_vtables: u64,
vnames_size: u64,
value_kind_last: u64,
}
#[allow(dead_code)]
struct ProfileData {
name_ref: u64,
func_hash: u64,
counter_ptr: u64,
bitmap_ptr: u64,
function_ptr: u64,
values_ptr: u64,
num_counters: u32,
num_value_sites: [u16; 3],
num_bitmap_bytes: u32,
}
const DATA_RECORD_SIZE: usize = 64;
#[allow(dead_code)]
struct RawProfile {
num_data: u64,
num_counters: u64,
functions: Vec<FunctionCounters>,
names_size: u64,
}
struct FunctionCounters {
num_counters: u32,
counters: Vec<u64>,
covered: u32,
}
fn parse_raw_profile(data: &[u8]) -> Result<RawProfile, String> {
if data.len() < 128 {
return Err(format!(
"file too small: {} bytes (need at least 128)",
data.len()
));
}
let h = unsafe { &*(data.as_ptr() as *const RawHeader) };
if h.magic != RAW_MAGIC {
return Err(format!(
"bad magic: 0x{:016x} (expected 0x{:016x})",
h.magic, RAW_MAGIC
));
}
let version = h.version & 0x00000000ffffffff;
if version != EXPECTED_VERSION {
return Err(format!(
"unsupported profile version: {} (expected {})",
version, EXPECTED_VERSION
));
}
let mut offset: usize = 128;
let bin_ids_size = h.binary_ids_size as usize;
offset += bin_ids_size;
let num_data = h.num_data as usize;
let data_size = num_data * DATA_RECORD_SIZE;
if offset + data_size > data.len() {
return Err(format!(
"data records extend past end of file (offset={}, need {}, file={})",
offset,
data_size,
data.len()
));
}
let mut functions = Vec::with_capacity(num_data);
for i in 0..num_data {
let rec_offset = offset + i * DATA_RECORD_SIZE;
let rec = read_data_record(&data[rec_offset..]);
functions.push(FunctionCounters {
num_counters: rec.num_counters,
counters: Vec::new(),
covered: 0,
});
}
offset += data_size;
let num_counters = h.num_counters as usize;
let counters_end = offset + num_counters * 8;
if counters_end > data.len() {
return Err(format!(
"counters extend past end of file (offset={}, need {}, file={})",
offset,
num_counters * 8,
data.len()
));
}
let mut ci = 0usize;
for func in &mut functions {
let n = func.num_counters as usize;
let mut covered = 0u32;
let mut vals = Vec::with_capacity(n);
for j in 0..n {
let val = u64::from_le_bytes(
data[offset + (ci + j) * 8..offset + (ci + j) * 8 + 8]
.try_into()
.unwrap(),
);
if val > 0 {
covered += 1;
}
vals.push(val);
}
func.counters = vals;
func.covered = covered;
ci += n;
}
offset += num_counters * 8;
let names_size = h.names_size as usize;
let _names = &data[offset..offset + names_size.min(data.len().saturating_sub(offset))];
Ok(RawProfile {
num_data: h.num_data,
num_counters: h.num_counters,
functions,
names_size: h.names_size,
})
}
fn read_data_record(buf: &[u8]) -> ProfileData {
let get = |off: usize| -> u64 {
u64::from_le_bytes(buf[off..off + 8].try_into().unwrap())
};
ProfileData {
name_ref: get(0),
func_hash: get(8),
counter_ptr: get(16),
bitmap_ptr: get(24),
function_ptr: get(32),
values_ptr: get(40),
num_counters: {
let arr: [u8; 4] = buf[48..52].try_into().unwrap();
u32::from_le_bytes(arr)
},
num_value_sites: [
u16::from_le_bytes(buf[52..54].try_into().unwrap()),
u16::from_le_bytes(buf[54..56].try_into().unwrap()),
u16::from_le_bytes(buf[56..58].try_into().unwrap()),
],
num_bitmap_bytes: {
let arr: [u8; 4] = buf[60..64].try_into().unwrap();
u32::from_le_bytes(arr)
},
}
}
pub fn compute_coverage_from_profraw(path: &Path) -> Result<CoverageTotals, String> {
let data = std::fs::read(path).map_err(|e| format!("read {:?}: {e}", path))?;
let profile = parse_raw_profile(&data)?;
if profile.functions.is_empty() {
return Ok(CoverageTotals::new());
}
let total_counters = profile
.functions
.iter()
.map(|f| f.num_counters as u64)
.sum::<u64>();
let covered_counters = profile
.functions
.iter()
.map(|f| f.covered as u64)
.sum::<u64>();
let total_funcs = profile.functions.len() as u64;
let covered_funcs = profile
.functions
.iter()
.filter(|f| f.covered > 0)
.count() as u64;
Ok(CoverageTotals {
total_counters,
covered_counters,
total_functions: total_funcs,
covered_functions: covered_funcs,
})
}
pub struct CoverageTotals {
total_counters: u64,
covered_counters: u64,
total_functions: u64,
covered_functions: u64,
}
impl CoverageTotals {
fn new() -> Self {
CoverageTotals { total_counters: 0, covered_counters: 0, total_functions: 0, covered_functions: 0 }
}
fn add(&mut self, other: &CoverageTotals) {
self.total_counters += other.total_counters;
self.covered_counters += other.covered_counters;
self.total_functions += other.total_functions;
self.covered_functions += other.covered_functions;
}
fn line_pct(&self) -> f64 {
if self.total_counters > 0 {
(self.covered_counters as f64 / self.total_counters as f64 * 100.0).min(100.0)
} else {
0.0
}
}
fn func_pct(&self) -> f64 {
if self.total_functions > 0 {
(self.covered_functions as f64 / self.total_functions as f64 * 100.0).min(100.0)
} else {
0.0
}
}
fn region_pct(&self) -> f64 {
self.line_pct()
}
}
pub struct RawCoverageRunner {
pub output_dir: PathBuf,
pub extra_test_args: Vec<String>,
}
impl RawCoverageRunner {
pub fn run(&self, format: CoverageFormat) -> Result<CoverageReport, String> {
let out_dir = &self.output_dir;
std::fs::create_dir_all(out_dir)
.map_err(|e| format!("mkdir {:?}: {e}", out_dir))?;
let profraw_pattern = out_dir.join("test_%p.profraw");
let build = self.cargo_test_no_run()?;
let binaries = parse_test_binaries(&build.stdout);
if binaries.is_empty() {
return Err("no test binaries produced".into());
}
for bin in &binaries {
let status = Command::new(bin)
.env(
"LLVM_PROFILE_FILE",
profraw_pattern.to_str().unwrap(),
)
.args(&self.extra_test_args)
.status()
.map_err(|e| format!("run {:?}: {e}", bin))?;
if !status.success() {
eprintln!("warning: {:?} exited non-zero", bin);
}
}
let mut totals = CoverageTotals::new();
let entries = std::fs::read_dir(out_dir)
.map_err(|e| format!("read_dir {:?}: {e}", out_dir))?;
for entry in entries {
let entry = entry.map_err(|e| format!("entry: {e}"))?;
let path = entry.path();
if path.extension().map_or(true, |e| e != "profraw") {
continue;
}
match compute_coverage_from_profraw(&path) {
Ok(t) => totals.add(&t),
Err(e) => {
eprintln!("warning: skipping {:?}: {e}", path);
}
}
let _ = std::fs::remove_file(&path);
}
if totals.total_counters == 0 {
return Err("no .profraw files generated or all were empty".into());
}
let line_cov = totals.line_pct();
let func_cov = totals.func_pct();
let region_cov = totals.region_pct();
let report_path = match format {
CoverageFormat::Summary => None,
_ => {
let path = out_dir.join(report_filename(format));
let summary = format!(
"Lines: {:.1}%\nFunctions: {:.1}%\nRegions: {:.1}%\n",
line_cov, func_cov, region_cov
);
std::fs::write(&path, &summary)
.map_err(|e| format!("write {:?}: {e}", path))?;
Some(path)
}
};
Ok(CoverageReport {
line_coverage: line_cov,
function_coverage: func_cov,
region_coverage: region_cov,
format,
report_path,
})
}
fn cargo_test_no_run(&self) -> Result<std::process::Output, String> {
let mut cmd = Command::new("cargo");
cmd.args(["test", "--no-run", "--message-format=json"])
.env("CARGO_INCREMENTAL", "0")
.env("RUSTFLAGS", "-Cinstrument-coverage")
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::inherit());
if !self.extra_test_args.is_empty() {
cmd.arg("--").args(&self.extra_test_args);
}
cmd.output()
.map_err(|e| format!("cargo test --no-run: {e}"))
}
}
pub fn write_report(format: CoverageFormat, line_cov: f64, func_cov: f64, region_cov: f64, path: &Path) -> Result<(), String> {
let content = match format {
CoverageFormat::Summary => String::new(),
_ => format!(
"Lines: {:.1}%\nFunctions: {:.1}%\nRegions: {:.1}%\n",
line_cov, func_cov, region_cov
),
};
if !content.is_empty() {
std::fs::write(path, &content).map_err(|e| format!("write {:?}: {e}", path))?;
}
Ok(())
}
pub fn report_filename(format: CoverageFormat) -> String {
match format {
CoverageFormat::Summary => "summary.txt".into(),
CoverageFormat::Html => "index.html".into(),
CoverageFormat::Lcov => "lcov.info".into(),
CoverageFormat::Json => "coverage.json".into(),
CoverageFormat::Cobertura => "cobertura.xml".into(),
}
}
pub fn parse_test_binaries(json_output: &[u8]) -> Vec<PathBuf> {
use serde::Deserialize;
#[derive(Deserialize)]
struct CargoArtifact {
reason: String,
filenames: Vec<String>,
#[serde(default)]
target_kind: Vec<String>,
#[serde(default)]
profile: Option<ArtifactProfile>,
}
#[derive(Deserialize)]
struct ArtifactProfile {
#[serde(rename = "test")]
is_test: bool,
}
let text = String::from_utf8_lossy(json_output);
let mut binaries = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Ok(artifact) = serde_json::from_str::<CargoArtifact>(line) {
if artifact.reason != "compiler-artifact" {
continue;
}
let is_test_bin = artifact
.profile
.as_ref()
.map(|p| p.is_test)
.unwrap_or(false)
|| artifact.target_kind.iter().any(|k| k == "bin" || k == "test");
if !is_test_bin {
continue;
}
let only_doc_test = artifact.target_kind.iter().all(|k| k == "test")
&& artifact.filenames.iter().any(|f| {
let stem = Path::new(f).file_stem().and_then(|s| s.to_str()).unwrap_or("");
!stem.contains("integration") && !stem.contains("cargo_rvtest") && !stem.contains("rvtest-")
});
if only_doc_test {
continue;
}
for filename in &artifact.filenames {
let path = PathBuf::from(filename);
if path.is_file() {
binaries.push(path);
}
}
}
}
binaries
}
impl fmt::Display for CoverageFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
CoverageFormat::Summary => "summary",
CoverageFormat::Html => "html",
CoverageFormat::Lcov => "lcov",
CoverageFormat::Json => "json",
CoverageFormat::Cobertura => "cobertura",
};
write!(f, "{s}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_coverage_totals_empty() {
let t = CoverageTotals::new();
assert_eq!(t.line_pct(), 0.0);
assert_eq!(t.func_pct(), 0.0);
}
#[test]
fn test_coverage_totals_aggregation() {
let mut t = CoverageTotals::new();
t.add(&CoverageTotals { total_counters: 100, covered_counters: 80, total_functions: 10, covered_functions: 8 });
t.add(&CoverageTotals { total_counters: 200, covered_counters: 150, total_functions: 20, covered_functions: 15 });
assert!((t.line_pct() - 76.666).abs() < 0.01); assert!((t.func_pct() - 76.666).abs() < 0.01); }
#[test]
fn test_coverage_totals_always_within_100() {
let mut t = CoverageTotals::new();
t.add(&CoverageTotals { total_counters: 10, covered_counters: 20, total_functions: 10, covered_functions: 10 });
assert_eq!(t.line_pct(), 100.0);
assert_eq!(t.func_pct(), 100.0);
}
#[test]
fn test_report_filename() {
assert_eq!(report_filename(CoverageFormat::Summary), "summary.txt");
assert_eq!(report_filename(CoverageFormat::Html), "index.html");
assert_eq!(report_filename(CoverageFormat::Lcov), "lcov.info");
assert_eq!(report_filename(CoverageFormat::Json), "coverage.json");
assert_eq!(report_filename(CoverageFormat::Cobertura), "cobertura.xml");
}
#[test]
fn test_parse_test_binaries_empty() {
let bins = parse_test_binaries(b"");
assert!(bins.is_empty());
}
#[test]
fn test_parse_test_binaries_non_json() {
let bins = parse_test_binaries(b"not json\nat all");
assert!(bins.is_empty());
}
#[test]
fn test_parse_test_binaries_ignores_non_artifact() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/test_bin"],"target_kind":["bin"],"profile":{"test":true}}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty());
}
#[test]
fn test_parse_test_binaries_filters_library_artifacts() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/lib.rlib"],"target_kind":["lib"],"profile":{"test":false}}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty(), "should filter non-test artifacts");
}
#[test]
fn test_write_report_summary_does_nothing() {
let dir = std::env::temp_dir().join("rvtest_cov_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("summary.txt");
let result = write_report(CoverageFormat::Summary, 50.0, 60.0, 50.0, &path);
assert!(result.is_ok());
assert!(!path.exists(), "summary should not write a file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_write_report_json_writes_file() {
let dir = std::env::temp_dir().join("rvtest_cov_test_json");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("coverage.json");
let result = write_report(CoverageFormat::Json, 75.0, 80.0, 75.0, &path);
assert!(result.is_ok());
assert!(path.exists());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("75.0"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_parse_raw_profile_bad_magic() {
let data = vec![0u8; 256];
let result = parse_raw_profile(&data);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.contains("magic"), "error should mention magic, got: {e}");
}
}
#[test]
fn test_parse_raw_profile_too_small() {
let result = parse_raw_profile(&[0u8; 10]);
assert!(result.is_err());
}
#[test]
fn test_parse_raw_profile_valid_empty() {
let magic = 0xff6c70726f667281u64.to_le_bytes();
let version = 10u64.to_le_bytes();
let zeros: [u8; 112] = [0; 112];
let mut data = Vec::new();
data.extend_from_slice(&magic);
data.extend_from_slice(&version);
data.extend_from_slice(&zeros);
let result = parse_raw_profile(&data);
assert!(result.is_ok(), "valid empty profraw should parse: {:?}", result.err());
let profile = result.unwrap();
assert_eq!(profile.num_data, 0);
assert_eq!(profile.num_counters, 0);
assert!(profile.functions.is_empty());
}
#[test]
fn test_write_report_html() {
let dir = std::env::temp_dir().join("rvtest_cov_html");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("index.html");
let result = write_report(CoverageFormat::Html, 50.0, 60.0, 50.0, &path);
assert!(result.is_ok());
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_write_report_lcov() {
let dir = std::env::temp_dir().join("rvtest_cov_lcov");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("lcov.info");
let result = write_report(CoverageFormat::Lcov, 70.0, 80.0, 70.0, &path);
assert!(result.is_ok());
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_coverage_format_display() {
assert_eq!(format!("{}", CoverageFormat::Summary), "summary");
assert_eq!(format!("{}", CoverageFormat::Html), "html");
assert_eq!(format!("{}", CoverageFormat::Lcov), "lcov");
assert_eq!(format!("{}", CoverageFormat::Json), "json");
assert_eq!(format!("{}", CoverageFormat::Cobertura), "cobertura");
}
#[test]
fn test_parse_test_binaries_ignores_wrong_reason() {
let input = br#"{"reason":"build-script-executed","filenames":[],"target_kind":[]}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty());
}
#[test]
fn test_parse_test_binaries_doc_test_filtered() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/rvtest-abc123"],"target_kind":["test"],"profile":{"test":true}}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty(), "doc-test only binaries should be filtered");
}
#[test]
fn test_parse_test_binaries_integration_not_filtered() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/integration-abc123"],"target_kind":["test"],"profile":{"test":true}}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty(), "should be empty (file doesn't exist), not filtered as doc-test");
}
#[test]
fn test_parse_test_binaries_no_profile_falls_back_to_target_kind() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/nonexistent_bin"],"target_kind":["bin"]}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty(), "file doesn't exist but should pass the test binary check");
}
#[test]
fn test_parse_test_binaries_no_profile_not_bin_or_test() {
let input = br#"{"reason":"compiler-artifact","filenames":["/tmp/lib.rlib"],"target_kind":["lib"]}"#;
let bins = parse_test_binaries(input);
assert!(bins.is_empty(), "lib without test profile should be filtered");
}
#[test]
fn test_parse_test_binaries_empty_line_skipped() {
let input = b"\n\n";
let bins = parse_test_binaries(input);
assert!(bins.is_empty());
}
fn build_profraw(num_data: u64, num_counters: u64, counter_values: &[u64]) -> Vec<u8> {
let mut data = Vec::new();
data.extend_from_slice(&0xff6c70726f667281u64.to_le_bytes()); data.extend_from_slice(&10u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&num_data.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&num_counters.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes());
for _ in 0..num_data {
let n_counters = (num_counters / num_data.max(1)) as u32;
data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&0u64.to_le_bytes()); data.extend_from_slice(&n_counters.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0u16.to_le_bytes()); data.extend_from_slice(&0u32.to_le_bytes()); }
for val in counter_values {
data.extend_from_slice(&val.to_le_bytes());
}
data
}
#[test]
fn test_parse_raw_profile_version_error() {
let mut data = build_profraw(0, 0, &[]);
data[8..16].copy_from_slice(&99u64.to_le_bytes());
let result = parse_raw_profile(&data);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.contains("version"), "error should mention version, got: {e}");
}
}
#[test]
fn test_parse_raw_profile_data_overflow() {
let counters: [u64; 0] = [];
let mut data = build_profraw(5, 0, &counters);
data.truncate(128);
let result = parse_raw_profile(&data);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.contains("data records"), "error should mention data records, got: {e}");
}
}
#[test]
fn test_parse_raw_profile_counters_overflow() {
let counters: [u64; 2] = [1, 2];
let mut data = build_profraw(1, 100, &counters);
data.truncate(128 + 64); let result = parse_raw_profile(&data);
assert!(result.is_err());
}
#[test]
fn test_parse_raw_profile_with_one_function() {
let counters = [0u64, 5, 10];
let data = build_profraw(1, 3, &counters);
let result = parse_raw_profile(&data);
assert!(result.is_ok(), "should parse valid profraw: {:?}", result.err());
let profile = result.unwrap();
assert_eq!(profile.num_data, 1);
assert_eq!(profile.num_counters, 3);
assert_eq!(profile.functions.len(), 1);
assert_eq!(profile.functions[0].num_counters, 3);
assert_eq!(profile.functions[0].counters, vec![0, 5, 10]);
assert_eq!(profile.functions[0].covered, 2);
}
#[test]
fn test_parse_raw_profile_with_two_functions() {
let counters = [1u64, 2, 3, 0, 0, 5];
let data = build_profraw(2, 6, &counters);
let result = parse_raw_profile(&data);
assert!(result.is_ok(), "should parse multi-function profraw: {:?}", result.err());
let profile = result.unwrap();
assert_eq!(profile.functions.len(), 2);
assert_eq!(profile.functions[0].counters, vec![1, 2, 3]);
assert_eq!(profile.functions[0].covered, 3);
assert_eq!(profile.functions[1].counters, vec![0, 0, 5]);
assert_eq!(profile.functions[1].covered, 1);
}
#[test]
fn test_compute_coverage_from_profraw() {
let counters = [0u64, 10, 20, 0, 0];
let data = build_profraw(1, 5, &counters);
let dir = std::env::temp_dir().join("rvtest_cov_compute");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("test.profraw");
std::fs::write(&path, &data).unwrap();
let result = compute_coverage_from_profraw(&path);
assert!(result.is_ok(), "should compute coverage: {:?}", result.err());
let totals = result.unwrap();
assert!((totals.line_pct() - 40.0).abs() < 0.01);
assert!((totals.func_pct() - 100.0).abs() < 0.01);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_compute_coverage_from_profraw_not_found() {
let path = std::env::temp_dir().join("nonexistent_xyz123.profraw");
let result = compute_coverage_from_profraw(&path);
assert!(result.is_err());
}
#[test]
fn test_coverage_totals_region_pct() {
let t = CoverageTotals { total_counters: 100, covered_counters: 50, total_functions: 10, covered_functions: 5 };
assert_eq!(t.region_pct(), t.line_pct());
}
#[test]
fn test_write_report_cobertura() {
let dir = std::env::temp_dir().join("rvtest_cov_cobertura");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cobertura.xml");
let result = write_report(CoverageFormat::Cobertura, 60.0, 70.0, 60.0, &path);
assert!(result.is_ok());
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
}