use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;
use anyhow::{bail, Context, Result};
use crate::coverage::HitRange;
use crate::db::{affected_dir, Db, TestId, FINGERPRINT_KEEP};
use crate::fingerprint;
use crate::project::{
canonicalize_no_verbatim, find_project_root, git_head_sha, git_working_tree_dirty,
};
use crate::selection;
use crate::shim::{self, TestOutcome, TestResult};
pub fn collect(
diff: bool,
verbose: bool,
allow_dirty: bool,
nextest_args: &[String],
) -> Result<i32> {
let total_start = Instant::now();
let project = find_project_root()?;
let project_root = &project.workspace_root;
if verbose {
eprintln!("project root: {}", project_root.display());
}
let canonical_root = canonicalize_no_verbatim(project_root)?;
if git_working_tree_dirty(project_root)? {
if allow_dirty {
eprintln!(
"warning: collecting on a dirty working tree (--allow-dirty); \
stored ranges may not align with future `affected run` queries"
);
} else {
bail!(
"working tree has uncommitted changes; commit or stash them \
before `cargo affected collect`, or pass --allow-dirty for a \
throwaway run (selection will be unreliable)"
);
}
}
require_nextest(project_root)?;
let self_path = std::env::current_exe().context("failed to resolve current executable")?;
let target_triple = current_target();
let runner_config = format_runner_config(&target_triple, &self_path);
let llvm_profdata = find_llvm_tool("llvm-profdata")?;
let llvm_cov = find_llvm_tool("llvm-cov")?;
if verbose {
eprintln!(
"llvm-profdata: {}\nllvm-cov: {}",
llvm_profdata.display(),
llvm_cov.display()
);
}
let collect_sha = git_head_sha(project_root)?;
eprintln!("collect sha: {collect_sha}");
let build_dir = affected_dir(project_root).join("build");
std::fs::create_dir_all(&build_dir).context("failed to create build dir")?;
for entry in std::fs::read_dir(&build_dir).context("scanning build dir")? {
let entry = entry?;
if entry.path().extension().is_some_and(|e| e == "profraw") {
let _ = std::fs::remove_file(entry.path());
}
}
let pid = std::process::id();
let profraw_dir = affected_dir(project_root).join(format!("{PROFRAW_DIR_PREFIX}{pid}"));
let results_dir = affected_dir(project_root).join(format!("{RESULTS_DIR_PREFIX}{pid}"));
for dir in [&profraw_dir, &results_dir] {
if dir.exists() {
std::fs::remove_dir_all(dir)
.with_context(|| format!("failed to clean {}", dir.display()))?;
}
std::fs::create_dir_all(dir)
.with_context(|| format!("failed to create {}", dir.display()))?;
}
let crate_root_sentinels = project.crate_root_sentinels_by_binary_id()?;
if verbose {
for (binary_id, paths) in &crate_root_sentinels {
eprintln!(
"crate-root sentinels for {binary_id}: {}",
paths
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
}
let crate_root_ranges_by_binary_id: BTreeMap<String, BTreeSet<HitRange>> =
crate_root_sentinels
.into_iter()
.map(|(binary_id, paths)| {
let ranges = paths.into_iter().map(HitRange::sentinel).collect();
(binary_id, ranges)
})
.collect();
let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
if !rustflags.is_empty() {
rustflags.push(' ');
}
rustflags.push_str("-C instrument-coverage");
eprintln!("listing tests with cargo nextest list...");
let listing = nextest_list(
project_root,
Some(&rustflags),
Some(&build_dir),
&cargo_build_args(nextest_args),
None,
)?;
eprintln!(
"found {} tests across {} binaries",
listing.tests.len(),
listing.binaries.len()
);
let fingerprint = fingerprint::compute(&project)?;
let env_fingerprint = &fingerprint.hex;
let mut db = Db::open(project_root)?;
let diff_plan = if diff {
match plan_diff_collect(project_root, &db, env_fingerprint, &listing)? {
DiffOutcome::Plan(plan) => Some(plan),
DiffOutcome::NothingToRecollect { listed } => {
let pruned = db.prune_missing_tests(env_fingerprint, &listed)?;
if pruned > 0 {
let s = if pruned == 1 { "" } else { "s" };
eprintln!("pruned {pruned} test{s} no longer present in nextest list");
}
eprintln!(
"done. nothing to recollect — no affected tests and no new tests \
({:.1}s total)",
total_start.elapsed().as_secs_f64(),
);
return Ok(0);
}
}
} else {
None
};
let mut cmd = Command::new("cargo");
cmd.arg("nextest")
.arg("run")
.arg("--config")
.arg(&runner_config)
.arg("--target-dir")
.arg(&build_dir)
.arg("--no-tests=warn")
.env("RUSTFLAGS", &rustflags)
.env(shim::ENV_PROFRAW_BASE, &profraw_dir)
.env(shim::ENV_RESULTS_DIR, &results_dir)
.env(shim::ENV_LLVM_PROFDATA, &llvm_profdata)
.env(shim::ENV_LLVM_COV, &llvm_cov)
.env(shim::ENV_CANONICAL_ROOT, &canonical_root)
.env("LLVM_PROFILE_FILE", build_dir.join("build-%p-%m.profraw"))
.current_dir(project_root);
let filter_config = match &diff_plan {
Some(plan) => {
let config = write_nextest_config(project_root, &plan.filter_expr())?;
cmd.arg("--config-file").arg(&config);
Some(config)
}
None => None,
};
for a in nextest_args {
cmd.arg(a);
}
eprintln!("running tests (each extracts its own coverage as it finishes)...");
let status = cmd.status().context("failed to run cargo nextest run")?;
let nextest_exit = status.code().unwrap_or(1);
if let Some(config) = &filter_config {
let _ = std::fs::remove_file(config);
}
let results = read_results(&results_dir)?;
if results.is_empty() {
let exit = handle_no_results(
&mut db,
env_fingerprint,
diff_plan.as_ref(),
nextest_exit,
&results_dir,
)?;
remove_staging_dirs(&profraw_dir, &results_dir)?;
return Ok(exit);
}
let mut mappings: Vec<(TestId, BTreeSet<HitRange>)> = Vec::new();
let mut unknown_binaries: BTreeSet<String> = BTreeSet::new();
let mut skipped = 0usize;
for result in results {
match result.outcome {
TestOutcome::Collected { mut ranges } => {
let test_id = TestId::new(result.binary_id, result.test_name);
let Some(pkg_ranges) = crate_root_ranges_by_binary_id.get(&test_id.binary_id)
else {
unknown_binaries.insert(test_id.binary_id);
continue;
};
ranges.extend(pkg_ranges.iter().cloned());
mappings.push((test_id, ranges));
}
TestOutcome::Skipped { reason } => {
skipped += 1;
eprintln!(
" skipped {}::{}: {reason}",
result.binary_id, result.test_name
);
}
}
}
if !unknown_binaries.is_empty() {
bail!(
"coverage results reference binary_ids absent from the workspace \
(metadata/listing divergence): {}",
unknown_binaries.into_iter().collect::<Vec<_>>().join(", ")
);
}
if skipped > 0 {
let s = if skipped == 1 { "" } else { "s" };
eprintln!("{skipped} test{s} produced no coverage");
}
if mappings.is_empty() {
bail!(
"nextest ran but coverage extraction yielded no ranges for any of \
the {skipped} completed test{} (see the skip reasons above) — \
refusing to overwrite stored coverage",
if skipped == 1 { "" } else { "s" },
);
}
let total_elapsed = total_start.elapsed();
let region_count: usize = mappings.iter().map(|(_, r)| r.len()).sum();
if let Some(plan) = diff_plan {
eprintln!(
"updating coverage for {} tests ({region_count} ranges)...",
mappings.len()
);
db.update_coverage_for_tests(
env_fingerprint,
&fingerprint.components,
&collect_sha,
&mappings,
)?;
let pruned = db.prune_missing_tests(env_fingerprint, &plan.listed)?;
if pruned > 0 {
let s = if pruned == 1 { "" } else { "s" };
eprintln!("pruned {pruned} test{s} no longer present in nextest list");
}
} else {
eprintln!(
"storing coverage for {} tests ({region_count} ranges)...",
mappings.len()
);
db.store_coverage(
env_fingerprint,
&fingerprint.components,
&collect_sha,
&mappings,
)?;
}
let evicted = db.gc(env_fingerprint, FINGERPRINT_KEEP)?;
if evicted > 0 {
let kept = db.fingerprint_count()?;
let s = if evicted == 1 { "" } else { "s" };
eprintln!("evicted {evicted} stale fingerprint{s} (kept {kept} of {FINGERPRINT_KEEP})");
}
eprintln!(
"done. {} tests, {} ranges stored in target/affected/coverage.db ({:.1}s total)",
mappings.len(),
region_count,
total_elapsed.as_secs_f64(),
);
remove_staging_dirs(&profraw_dir, &results_dir)?;
Ok(nextest_exit)
}
const PROFRAW_DIR_PREFIX: &str = "profraw-";
const RESULTS_DIR_PREFIX: &str = "results-";
fn remove_staging_dirs(profraw_dir: &Path, results_dir: &Path) -> Result<()> {
for dir in [profraw_dir, results_dir] {
if dir.exists() {
std::fs::remove_dir_all(dir)
.with_context(|| format!("failed to remove {}", dir.display()))?;
}
}
Ok(())
}
pub(crate) fn clean_staging_dirs(project_root: &Path) -> Result<usize> {
let dir = affected_dir(project_root);
if !dir.exists() {
return Ok(0);
}
let mut removed = 0;
for entry in std::fs::read_dir(&dir).context("scanning target/affected")? {
let path = entry?.path();
let is_staging = path.is_dir()
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| {
n.starts_with(PROFRAW_DIR_PREFIX) || n.starts_with(RESULTS_DIR_PREFIX)
});
if is_staging {
std::fs::remove_dir_all(&path)
.with_context(|| format!("failed to remove {}", path.display()))?;
removed += 1;
}
}
Ok(removed)
}
fn read_results(results_dir: &Path) -> Result<Vec<TestResult>> {
let mut results = Vec::new();
if !results_dir.exists() {
return Ok(results);
}
for binary_entry in std::fs::read_dir(results_dir).context("scanning results dir")? {
let binary_path = binary_entry?.path();
if !binary_path.is_dir() {
continue;
}
for file_entry in std::fs::read_dir(&binary_path)? {
let path = file_entry?.path();
if path.extension().is_some_and(|e| e == "json") {
match std::fs::read_to_string(&path)
.map_err(anyhow::Error::from)
.and_then(|raw| Ok(serde_json::from_str::<TestResult>(&raw)?))
{
Ok(result) => results.push(result),
Err(e) => eprintln!(
"warning: ignoring unreadable coverage result {}: {e:#}",
path.display()
),
}
}
}
}
results.sort_by(|a, b| (&a.binary_id, &a.test_name).cmp(&(&b.binary_id, &b.test_name)));
Ok(results)
}
struct DiffPlan {
selected: BTreeSet<TestId>,
listed: BTreeSet<TestId>,
}
impl DiffPlan {
fn filter_expr(&self) -> String {
let v: Vec<TestId> = self.selected.iter().cloned().collect();
nextest_filter_expr(&v)
}
fn live_selected_count(&self) -> usize {
self.selected.iter().filter(|t| self.listed.contains(t)).count()
}
}
enum DiffOutcome {
Plan(DiffPlan),
NothingToRecollect {
listed: BTreeSet<TestId>,
},
}
fn plan_diff_collect(
project_root: &Path,
db: &Db,
env_fingerprint: &str,
listing: &Listing,
) -> Result<DiffOutcome> {
let prior_shas = db.collect_shas(env_fingerprint)?;
if prior_shas.is_empty() {
bail!(
"--diff requires a prior `cargo affected collect` for the \
current environment (Cargo.lock / rustc / build flags); \
no stored coverage matches"
);
}
let reach = selection::check_shas_reachable(project_root, &prior_shas)?;
if !reach.missing.is_empty() {
eprintln!(
"{}",
selection::missing_shas_notice(
&reach.missing,
"will be rerun and re-anchored at the new HEAD",
),
);
}
if reach.reachable.is_empty() {
bail!(
"no reachable collect_sha for the current environment (every \
stored sha is rebased away or otherwise unreachable from HEAD); \
run `cargo affected collect` to re-anchor"
);
}
if reach.max_commits_ahead > 0 {
eprintln!(
"note: {} commit(s) since prior collect — \
re-anchoring affected tests at the new HEAD",
reach.max_commits_ahead,
);
}
let sel = selection::select_with_reach(
project_root,
db,
env_fingerprint,
listing,
&reach,
selection::DiagnosticDetail::Summary,
)?;
let selected = sel.selected();
if selected.is_empty() {
return Ok(DiffOutcome::NothingToRecollect { listed: sel.listed });
}
eprintln!("\n{}\n", selection::format_summary(&sel, "to recollect", false));
Ok(DiffOutcome::Plan(DiffPlan {
selected,
listed: sel.listed,
}))
}
fn handle_no_results(
db: &mut Db,
env_fingerprint: &str,
diff_plan: Option<&DiffPlan>,
nextest_exit: i32,
results_dir: &Path,
) -> Result<i32> {
if nextest_exit != 0 {
bail!(
"nextest exited with code {nextest_exit} and produced no coverage \
results under {} — build or test failure (see nextest output \
above)",
results_dir.display(),
);
}
if let Some(plan) = diff_plan {
let live = plan.live_selected_count();
if live > 0 {
bail!(
"nextest exited 0 but {live} of {} selected tests should have \
been instrumented — no coverage results appeared under {}; \
the runner shim may have failed to fire",
plan.selected.len(),
results_dir.display(),
);
}
eprintln!(
"no tests rerun: every selected test is absent from the current \
nextest listing (renamed or deleted between collects)"
);
let pruned = db.prune_missing_tests(env_fingerprint, &plan.listed)?;
if pruned > 0 {
let s = if pruned == 1 { "" } else { "s" };
eprintln!("pruned {pruned} test{s} no longer present in nextest list");
}
return Ok(0);
}
eprintln!(
"no coverage results under {} — project may have no tests, or the \
runner shim may have failed to fire",
results_dir.display(),
);
Ok(0)
}
pub(crate) fn nextest_filter_expr(tests: &[TestId]) -> String {
if tests.is_empty() {
return "none()".to_string();
}
let mut by_binary: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for t in tests {
by_binary
.entry(t.binary_id.as_str())
.or_default()
.push(t.test_name.as_str());
}
by_binary
.into_iter()
.map(|(binary_id, names)| {
let inner = names
.iter()
.map(|n| format!("test(={n})"))
.collect::<Vec<_>>()
.join(" | ");
format!("(binary_id(={binary_id}) & ({inner}))")
})
.collect::<Vec<_>>()
.join(" | ")
}
pub(crate) fn write_nextest_config(project_root: &Path, filter_expr: &str) -> Result<PathBuf> {
let project_config = project_root.join(".config").join("nextest.toml");
let existing = match std::fs::read_to_string(&project_config) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => {
return Err(e)
.with_context(|| format!("failed to read {}", project_config.display()))
}
};
let mut doc: toml_edit::DocumentMut = existing
.parse()
.with_context(|| format!("failed to parse {}", project_config.display()))?;
let filter = match doc
.get("profile")
.and_then(|p| p.get("default"))
.and_then(|d| d.get("default-filter"))
.and_then(|v| v.as_str())
{
Some(existing) => format!("({filter_expr}) & ({existing})"),
None => filter_expr.to_string(),
};
doc["profile"]["default"]["default-filter"] = toml_edit::value(filter);
let dir = affected_dir(project_root);
std::fs::create_dir_all(&dir).context("failed to create target/affected dir")?;
let path = dir.join(format!("nextest-config-{}.toml", std::process::id()));
std::fs::write(&path, doc.to_string())
.with_context(|| format!("failed to write {}", path.display()))?;
Ok(path)
}
const BUILD_FLAGS_BARE: &[&str] = &[
"--workspace",
"--all",
"--lib",
"--bins",
"--examples",
"--tests",
"--benches",
"--all-targets",
"--all-features",
"--no-default-features",
"--release",
"-r",
"--frozen",
"--locked",
"--offline",
"--ignore-rust-version",
"--future-incompat-report",
"--unit-graph",
];
const BUILD_FLAGS_VALUED: &[&str] = &[
"--package",
"--exclude",
"--bin",
"--example",
"--test",
"--bench",
"--features",
"--cargo-profile",
"--target",
"--manifest-path",
"--build-jobs",
"--config",
];
const BUILD_FLAGS_SHORT_VALUED: &[&str] = &["-p", "-F", "-Z"];
pub(crate) fn cargo_build_args(nextest_args: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut iter = nextest_args.iter();
while let Some(arg) = iter.next() {
let name = arg.split('=').next().unwrap_or(arg);
if BUILD_FLAGS_BARE.contains(&name) {
out.push(arg.clone());
} else if BUILD_FLAGS_VALUED.contains(&name) {
out.push(arg.clone());
if !arg.contains('=') {
if let Some(value) = iter.next() {
out.push(value.clone());
}
}
} else if BUILD_FLAGS_SHORT_VALUED.contains(&arg.as_str()) {
out.push(arg.clone());
if let Some(value) = iter.next() {
out.push(value.clone());
}
} else if BUILD_FLAGS_SHORT_VALUED.iter().any(|s| arg.starts_with(*s)) {
out.push(arg.clone());
}
}
out
}
pub(crate) struct Listing {
pub(crate) tests: Vec<TestId>,
pub(crate) ignored: BTreeSet<TestId>,
pub(crate) binaries: Vec<BinaryEntry>,
}
#[derive(Debug, Clone)]
pub(crate) struct BinaryEntry {
#[allow(dead_code)]
pub(crate) binary_id: String,
}
pub(crate) fn nextest_list(
project_root: &Path,
rustflags_override: Option<&str>,
build_dir: Option<&Path>,
build_args: &[String],
filter_expr: Option<&str>,
) -> Result<Listing> {
let mut cmd = Command::new("cargo");
cmd.arg("nextest")
.arg("list")
.arg("--message-format")
.arg("json")
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.current_dir(project_root);
if let Some(rf) = rustflags_override {
cmd.env("RUSTFLAGS", rf);
}
if let Some(dir) = build_dir {
cmd.arg("--target-dir").arg(dir);
cmd.env("LLVM_PROFILE_FILE", dir.join("build-%p-%m.profraw"));
}
for a in build_args {
cmd.arg(a);
}
if let Some(expr) = filter_expr {
cmd.arg("-E").arg(expr);
}
let output = cmd
.spawn()
.context("failed to spawn cargo nextest list")?
.wait_with_output()
.context("failed to wait for cargo nextest list")?;
if !output.status.success() {
bail!("cargo nextest list failed (exit {:?})", output.status.code());
}
let stdout = std::str::from_utf8(&output.stdout)
.context("cargo nextest list stdout was not valid UTF-8")?;
let json: serde_json::Value =
serde_json::from_str(stdout).context("failed to parse nextest list JSON")?;
let mut tests = BTreeSet::new();
let mut ignored = BTreeSet::new();
let mut binaries = Vec::new();
if let Some(suites) = json.get("rust-suites").and_then(|v| v.as_object()) {
for suite in suites.values() {
let binary_id = suite
.get("binary-id")
.and_then(|v| v.as_str())
.context("nextest list entry missing binary-id")?
.to_string();
binaries.push(BinaryEntry {
binary_id: binary_id.clone(),
});
let Some(cases) = suite.get("testcases").and_then(|v| v.as_object()) else {
continue;
};
for (name, case) in cases {
let test_id = TestId::new(binary_id.clone(), name.clone());
let is_ignored = case
.get("ignored")
.and_then(|v| v.as_bool())
.context("nextest list testcase missing `ignored` flag")?;
if is_ignored {
ignored.insert(test_id.clone());
}
tests.insert(test_id);
}
}
}
Ok(Listing {
tests: tests.into_iter().collect(),
ignored,
binaries,
})
}
pub(crate) fn require_nextest(project_root: &Path) -> Result<()> {
let output = Command::new("cargo")
.arg("nextest")
.arg("--version")
.current_dir(project_root)
.stderr(std::process::Stdio::null())
.output();
let stdout = match output {
Ok(o) if o.status.success() => o.stdout,
_ => bail!(
"cargo-affected requires cargo-nextest >= {MIN_NEXTEST_VERSION}. \
Install it with `cargo install cargo-nextest --locked`."
),
};
let line = std::str::from_utf8(&stdout).unwrap_or_default().lines().next().unwrap_or("");
let version = line.split_whitespace().nth(1).unwrap_or("");
if !nextest_version_at_least(version, MIN_NEXTEST_VERSION) {
bail!(
"cargo-affected requires cargo-nextest >= {MIN_NEXTEST_VERSION} \
(NEXTEST_BINARY_ID env support); found {:?}. \
Upgrade with `cargo install cargo-nextest --locked`.",
version,
);
}
Ok(())
}
const MIN_NEXTEST_VERSION: &str = "0.9.116";
fn nextest_version_at_least(actual: &str, required: &str) -> bool {
fn parts(v: &str) -> Option<Vec<u64>> {
v.split(['-', '+']).next()?
.split('.')
.map(|p| p.parse().ok())
.collect()
}
match (parts(actual), parts(required)) {
(Some(a), Some(r)) => a >= r,
_ => false,
}
}
fn find_llvm_tool(name: &str) -> Result<PathBuf> {
let exe_name = format!("{name}{}", std::env::consts::EXE_SUFFIX);
if let Ok(output) = Command::new("rustc").arg("--print").arg("sysroot").output() {
if output.status.success() {
let sysroot = String::from_utf8_lossy(&output.stdout).trim().to_string();
let tool_path = PathBuf::from(&sysroot)
.join("lib")
.join("rustlib")
.join(current_target())
.join("bin")
.join(&exe_name);
if tool_path.exists() {
return Ok(tool_path);
}
}
}
#[cfg(target_os = "macos")]
if let Ok(output) = Command::new("xcrun").arg("--find").arg(name).output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
let which_cmd = if cfg!(windows) { "where" } else { "which" };
if let Ok(output) = Command::new(which_cmd).arg(&exe_name).output() {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let path = stdout.lines().next().unwrap_or("").trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
bail!(
"could not find {name}. Install `llvm-tools` via `rustup component add llvm-tools` \
or ensure {name} is on PATH"
)
}
fn format_runner_config(target_triple: &str, self_path: &Path) -> String {
let escaped = self_path
.to_string_lossy()
.replace('\\', r"\\")
.replace('"', "\\\"");
format!(r#"target.{target_triple}.runner=["{escaped}", "runner-shim"]"#)
}
fn current_target() -> String {
Command::new("rustc")
.arg("-vV")
.output()
.ok()
.and_then(|o| {
let stdout = String::from_utf8_lossy(&o.stdout).to_string();
stdout
.lines()
.find(|l| l.starts_with("host:"))
.map(|l| l.trim_start_matches("host:").trim().to_string())
})
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_expr_empty_matches_nothing() {
assert_eq!(nextest_filter_expr(&[]), "none()");
}
#[test]
fn filter_expr_groups_by_binary() {
let tests = vec![
TestId::new("mock-stub::builds", "builds"),
TestId::new("wt-perf::builds", "builds"),
TestId::new("worktrunk", "utils::tests::test_x"),
TestId::new("worktrunk", "utils::tests::test_y"),
];
assert_eq!(
nextest_filter_expr(&tests),
"(binary_id(=mock-stub::builds) & (test(=builds))) | \
(binary_id(=worktrunk) & (test(=utils::tests::test_x) | test(=utils::tests::test_y))) | \
(binary_id(=wt-perf::builds) & (test(=builds)))",
);
}
#[test]
fn cargo_build_args_keeps_build_flags_drops_run_only() {
let args: Vec<String> = [
"--features",
"shell-integration-tests",
"--no-fail-fast",
"--retries",
"2",
"--release",
"--no-tests=warn",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
cargo_build_args(&args),
vec!["--features", "shell-integration-tests", "--release"],
);
}
#[test]
fn cargo_build_args_handles_joined_and_short_forms() {
let args: Vec<String> = [
"--features=a,b",
"-p",
"mycrate",
"-r",
"--max-fail=3",
"some_test_filter",
]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
cargo_build_args(&args),
vec!["--features=a,b", "-p", "mycrate", "-r"],
);
}
#[test]
fn cargo_build_args_empty() {
assert!(cargo_build_args(&[]).is_empty());
}
#[test]
fn large_selection_travels_in_config_file_not_argv() {
let names: Vec<String> = (0..3000)
.map(|i| format!("really_long_test_name_for_overflow_check_{i}"))
.collect();
let tests: Vec<TestId> = names.iter().map(|n| TestId::new("worktrunk", n)).collect();
let expr = nextest_filter_expr(&tests);
assert!(
expr.len() > 32 * 1024,
"expected a large filterset, got {} bytes",
expr.len()
);
let dir = tempfile::tempdir().unwrap();
let config = write_nextest_config(dir.path(), &expr).unwrap();
assert!(config.starts_with(dir.path()));
let written = std::fs::read_to_string(&config).unwrap();
for n in &names {
assert!(written.contains(&format!("test(={n})")), "missing {n}");
}
}
#[test]
fn write_nextest_config_preserves_project_settings() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join(".config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("nextest.toml"),
"experimental = [\"setup-scripts\"]\n\
\n\
[profile.default]\n\
slow-timeout = \"60s\"\n\
\n\
[profile.ci]\n\
retries = 2\n\
\n\
[scripts.setup.build-bins]\n\
command = [\"bash\", \"-c\", \"true\"]\n\
\n\
[[profile.default.scripts]]\n\
filter = \"binary(integration)\"\n\
setup = \"build-bins\"\n",
)
.unwrap();
let config = write_nextest_config(dir.path(), "binary_id(=x) & test(=y)").unwrap();
let doc: toml_edit::DocumentMut =
std::fs::read_to_string(&config).unwrap().parse().unwrap();
assert_eq!(
doc["profile"]["default"]["default-filter"].as_str().unwrap(),
"binary_id(=x) & test(=y)",
);
assert_eq!(doc["experimental"].as_array().unwrap().len(), 1);
assert_eq!(
doc["profile"]["default"]["slow-timeout"].as_str().unwrap(),
"60s",
);
assert_eq!(doc["profile"]["ci"]["retries"].as_integer().unwrap(), 2);
assert!(doc["scripts"]["setup"]["build-bins"]["command"].is_array());
assert_eq!(
doc["profile"]["default"]["scripts"].as_array_of_tables().unwrap().len(),
1,
);
}
#[test]
fn write_nextest_config_intersects_existing_default_filter() {
let dir = tempfile::tempdir().unwrap();
let config_dir = dir.path().join(".config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("nextest.toml"),
"[profile.default]\ndefault-filter = \"not test(slow)\"\n",
)
.unwrap();
let config = write_nextest_config(dir.path(), "test(=y)").unwrap();
let doc: toml_edit::DocumentMut =
std::fs::read_to_string(&config).unwrap().parse().unwrap();
assert_eq!(
doc["profile"]["default"]["default-filter"].as_str().unwrap(),
"(test(=y)) & (not test(slow))",
);
}
#[test]
fn write_nextest_config_without_project_config() {
let dir = tempfile::tempdir().unwrap();
let config = write_nextest_config(dir.path(), "test(=solo)").unwrap();
assert!(config
.file_name()
.unwrap()
.to_str()
.unwrap()
.contains(&std::process::id().to_string()));
let doc: toml_edit::DocumentMut =
std::fs::read_to_string(&config).unwrap().parse().unwrap();
assert_eq!(
doc["profile"]["default"]["default-filter"].as_str().unwrap(),
"test(=solo)",
);
}
#[test]
fn runner_config_uses_toml_array_form() {
let path = PathBuf::from("/usr/local/bin/cargo-affected");
assert_eq!(
format_runner_config("aarch64-apple-darwin", &path),
r#"target.aarch64-apple-darwin.runner=["/usr/local/bin/cargo-affected", "runner-shim"]"#,
);
}
#[test]
fn runner_config_preserves_spaces_in_path() {
let path = PathBuf::from("/Users/Joe Smith/.cargo/bin/cargo-affected");
assert_eq!(
format_runner_config("aarch64-apple-darwin", &path),
r#"target.aarch64-apple-darwin.runner=["/Users/Joe Smith/.cargo/bin/cargo-affected", "runner-shim"]"#,
);
}
#[test]
fn runner_config_escapes_backslashes_and_quotes() {
let path = PathBuf::from(r#"C:\Users\Joe "Q" Smith\cargo-affected.exe"#);
assert_eq!(
format_runner_config("x86_64-pc-windows-msvc", &path),
r#"target.x86_64-pc-windows-msvc.runner=["C:\\Users\\Joe \"Q\" Smith\\cargo-affected.exe", "runner-shim"]"#,
);
}
#[test]
fn nextest_version_compares_dotted_numbers() {
assert!(nextest_version_at_least("0.9.116", "0.9.116"));
assert!(nextest_version_at_least("0.9.132", "0.9.116"));
assert!(nextest_version_at_least("0.10.0", "0.9.116"));
assert!(nextest_version_at_least("1.0.0", "0.9.116"));
assert!(!nextest_version_at_least("0.9.115", "0.9.116"));
assert!(!nextest_version_at_least("0.9.99", "0.9.116"));
assert!(!nextest_version_at_least("0.8.999", "0.9.116"));
assert!(nextest_version_at_least("0.9.132-dev", "0.9.116"));
assert!(nextest_version_at_least("0.9.116+sha.abc", "0.9.116"));
assert!(!nextest_version_at_least("garbage", "0.9.116"));
assert!(!nextest_version_at_least("", "0.9.116"));
}
#[test]
fn read_results_skips_unreadable_files() {
let tmp = tempfile::tempdir().unwrap();
let results = tmp.path();
let dir = results.join("crate-a");
std::fs::create_dir_all(&dir).unwrap();
let good = TestResult {
binary_id: "crate-a".into(),
test_name: "t_ok".into(),
outcome: TestOutcome::Skipped { reason: "x".into() },
};
std::fs::write(dir.join("t_ok.json"), serde_json::to_vec(&good).unwrap()).unwrap();
std::fs::write(dir.join("t_torn.json"), b"{ not valid json").unwrap();
std::fs::write(dir.join("t_partial.json.tmp"), b"{").unwrap();
let got = read_results(results).unwrap();
assert_eq!(got.len(), 1, "only the parseable result survives");
assert_eq!(got[0].test_name, "t_ok");
}
#[test]
fn clean_staging_dirs_removes_only_staging() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let affected = root.join("target").join("affected");
std::fs::create_dir_all(affected.join("profraw-123")).unwrap();
std::fs::create_dir_all(affected.join("results-456")).unwrap();
std::fs::create_dir_all(affected.join("build")).unwrap();
std::fs::write(affected.join("coverage.db"), b"db").unwrap();
let removed = clean_staging_dirs(root).unwrap();
assert_eq!(removed, 2);
assert!(!affected.join("profraw-123").exists());
assert!(!affected.join("results-456").exists());
assert!(affected.join("build").exists(), "build dir preserved");
assert!(affected.join("coverage.db").exists(), "DB preserved");
}
}