use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::coverage::{self, HitRange};
pub(crate) const ENV_PROFRAW_BASE: &str = "CARGO_AFFECTED_PROFRAW_BASE";
pub(crate) const ENV_RESULTS_DIR: &str = "CARGO_AFFECTED_RESULTS_DIR";
pub(crate) const ENV_LLVM_PROFDATA: &str = "CARGO_AFFECTED_LLVM_PROFDATA";
pub(crate) const ENV_LLVM_COV: &str = "CARGO_AFFECTED_LLVM_COV";
pub(crate) const ENV_CANONICAL_ROOT: &str = "CARGO_AFFECTED_CANONICAL_ROOT";
#[derive(Serialize, Deserialize)]
pub(crate) struct TestResult {
pub(crate) binary_id: String,
pub(crate) test_name: String,
pub(crate) outcome: TestOutcome,
}
#[derive(Serialize, Deserialize)]
pub(crate) enum TestOutcome {
Collected { ranges: BTreeSet<HitRange> },
Skipped { reason: String },
}
pub fn run(args: &[String]) -> ! {
let Some((binary, rest)) = args.split_first() else {
eprintln!("cargo-affected runner-shim: missing test binary argument");
std::process::exit(2);
};
let (Ok(binary_id), Ok(test_name)) = (
std::env::var("NEXTEST_BINARY_ID"),
std::env::var("NEXTEST_TEST_NAME"),
) else {
std::process::exit(run_test(binary, rest));
};
let env = CoverageEnv::from_env();
let dir = env
.profraw_base
.join(sanitize(&binary_id))
.join(sanitize(&test_name));
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!(
"cargo-affected runner-shim: failed to create {}: {e}",
dir.display()
);
std::process::exit(2);
}
std::env::set_var("LLVM_PROFILE_FILE", dir.join("%p-%m.profraw"));
let code = run_test(binary, rest);
let outcome = extract(&dir, Path::new(binary), &env);
write_result(&env.results_dir, &binary_id, &test_name, outcome);
let _ = std::fs::remove_dir_all(&dir);
std::process::exit(code);
}
fn run_test(binary: &str, rest: &[String]) -> i32 {
match Command::new(binary).args(rest).status() {
Ok(status) => status.code().unwrap_or(1),
Err(e) => {
eprintln!("cargo-affected runner-shim: spawn {binary} failed: {e}");
127
}
}
}
struct CoverageEnv {
profraw_base: PathBuf,
results_dir: PathBuf,
llvm_profdata: PathBuf,
llvm_cov: PathBuf,
canonical_root: PathBuf,
}
impl CoverageEnv {
fn from_env() -> Self {
let var = |name: &str| -> PathBuf {
std::env::var_os(name)
.map(PathBuf::from)
.unwrap_or_else(|| {
eprintln!("cargo-affected runner-shim: {name} not set");
std::process::exit(2);
})
};
Self {
profraw_base: var(ENV_PROFRAW_BASE),
results_dir: var(ENV_RESULTS_DIR),
llvm_profdata: var(ENV_LLVM_PROFDATA),
llvm_cov: var(ENV_LLVM_COV),
canonical_root: var(ENV_CANONICAL_ROOT),
}
}
}
fn extract(dir: &Path, binary: &Path, env: &CoverageEnv) -> TestOutcome {
let profraw_files = match list_profraw_files(dir) {
Ok(files) => files,
Err(e) => {
return TestOutcome::Skipped {
reason: format!("listing profraw files: {e}"),
}
}
};
if profraw_files.is_empty() {
return TestOutcome::Skipped {
reason: "no profraw generated".into(),
};
}
let profdata_path = dir.join("coverage.profdata");
let mut merge_cmd = Command::new(&env.llvm_profdata);
merge_cmd.arg("merge").arg("--sparse");
for f in &profraw_files {
merge_cmd.arg(f);
}
merge_cmd.arg("-o").arg(&profdata_path);
let merge_output = match merge_cmd.output() {
Ok(output) => output,
Err(e) => {
return TestOutcome::Skipped {
reason: format!("llvm-profdata merge failed to run: {e}"),
}
}
};
if !merge_output.status.success() {
return TestOutcome::Skipped {
reason: format!(
"llvm-profdata merge failed: {}",
String::from_utf8_lossy(&merge_output.stderr).trim()
),
};
}
let export_output = match Command::new(&env.llvm_cov)
.arg("export")
.arg("--format=text")
.arg(format!("--instr-profile={}", profdata_path.display()))
.arg("--ignore-filename-regex=/rustc/|/\\.cargo/|/target/")
.arg(binary)
.output()
{
Ok(output) => output,
Err(e) => {
return TestOutcome::Skipped {
reason: format!("llvm-cov export failed to run: {e}"),
}
}
};
if !export_output.status.success() {
return TestOutcome::Skipped {
reason: format!(
"llvm-cov export failed: {}",
String::from_utf8_lossy(&export_output.stderr).trim()
),
};
}
let json = String::from_utf8_lossy(&export_output.stdout);
match coverage::extract_hit_ranges(&json, &env.canonical_root) {
Ok(ranges) => TestOutcome::Collected { ranges },
Err(e) => TestOutcome::Skipped {
reason: format!("parse error: {e}"),
},
}
}
fn write_result(results_dir: &Path, binary_id: &str, test_name: &str, outcome: TestOutcome) {
let dir = results_dir.join(sanitize(binary_id));
let path = dir.join(format!("{}.json", sanitize(test_name)));
let tmp = path.with_extension("json.tmp");
let result = TestResult {
binary_id: binary_id.to_string(),
test_name: test_name.to_string(),
outcome,
};
let write = || -> std::io::Result<()> {
std::fs::create_dir_all(&dir)?;
std::fs::write(&tmp, serde_json::to_vec(&result)?)?;
std::fs::rename(&tmp, &path)
};
if let Err(e) = write() {
eprintln!(
"cargo-affected runner-shim: failed to write result {}: {e}",
path.display()
);
}
}
fn list_profraw_files(dir: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
for entry in std::fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "profraw") {
files.push(path);
}
}
Ok(files)
}
pub fn sanitize(name: &str) -> String {
let mut out = String::with_capacity(name.len());
for c in name.chars() {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
out.push(c);
} else {
out.push('_');
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sanitize_passthrough() {
assert_eq!(sanitize("plain_name"), "plain_name");
assert_eq!(sanitize("dotted.name-1"), "dotted.name-1");
}
#[test]
fn sanitize_replaces_hostile_chars() {
assert_eq!(sanitize("math::tests::test_add"), "math__tests__test_add");
assert_eq!(sanitize("mock-stub::builds"), "mock-stub__builds");
assert_eq!(sanitize("a/b"), "a_b");
assert_eq!(sanitize("a\\b"), "a_b");
assert_eq!(sanitize("a b"), "a_b");
}
#[test]
fn extract_without_profraw_skips() {
let tmp = tempfile::tempdir().unwrap();
let env = CoverageEnv {
profraw_base: tmp.path().to_path_buf(),
results_dir: tmp.path().to_path_buf(),
llvm_profdata: PathBuf::from("llvm-profdata"),
llvm_cov: PathBuf::from("llvm-cov"),
canonical_root: tmp.path().to_path_buf(),
};
let outcome = extract(tmp.path(), Path::new("test-bin"), &env);
match outcome {
TestOutcome::Skipped { reason } => assert_eq!(reason, "no profraw generated"),
TestOutcome::Collected { .. } => panic!("expected Skipped, got Collected"),
}
}
#[test]
fn write_result_round_trips() {
use camino::Utf8PathBuf;
let tmp = tempfile::tempdir().unwrap();
let results = tmp.path();
let ranges: BTreeSet<HitRange> = [HitRange {
file: Utf8PathBuf::from("src/lib.rs"),
line_start: 3,
line_end: 7,
}]
.into_iter()
.collect();
write_result(
results,
"my-crate::tests",
"math::adds",
TestOutcome::Collected { ranges },
);
let path = results.join("my-crate__tests").join("math__adds.json");
let raw = std::fs::read_to_string(&path).unwrap();
let parsed: TestResult = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.binary_id, "my-crate::tests");
assert_eq!(parsed.test_name, "math::adds");
match parsed.outcome {
TestOutcome::Collected { ranges } => {
assert_eq!(ranges.len(), 1);
let r = ranges.iter().next().unwrap();
assert_eq!(r.file, "src/lib.rs");
assert_eq!((r.line_start, r.line_end), (3, 7));
}
TestOutcome::Skipped { .. } => panic!("expected Collected"),
}
}
}