use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use super::export::build_test_binaries;
#[derive(Debug)]
pub(crate) struct ProbeMiss {
pub bins_tried: usize,
pub rejection_stderr: Option<String>,
pub last_miss_stderr: String,
}
impl ProbeMiss {
pub(crate) fn render(&self, test: &str, rejection_subject: &str) -> String {
if let Some(reason) = &self.rejection_stderr {
return format!(
"test '{test}' is registered but {rejection_subject}:\n{}",
reason.trim_end(),
);
}
format!(
"test '{test}' not found in any workspace test binary ({} candidates tried). \
Last stderr from a candidate:\n{}",
self.bins_tried,
self.last_miss_stderr.trim_end(),
)
}
}
#[derive(Debug)]
pub(crate) enum ProbeError {
Setup(String),
Miss(ProbeMiss),
}
enum BinOutcome<T> {
Success(T),
Continue,
}
fn process_bin<T>(
bin: &Path,
configure_cmd: &impl Fn(&Path) -> Command,
on_success: &impl Fn(&Path, &Output) -> Result<T, String>,
rejection_stderr: &mut Option<String>,
last_miss_stderr: &mut String,
) -> Result<BinOutcome<T>, ProbeError> {
let mut cmd = configure_cmd(bin);
let out = cmd
.output()
.map_err(|e| ProbeError::Setup(format!("exec {}: {e}", bin.display())))?;
if out.status.success() {
let value = on_success(bin, &out).map_err(ProbeError::Setup)?;
return Ok(BinOutcome::Success(value));
}
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
if out.status.code() == Some(2) {
if rejection_stderr.is_none() {
*rejection_stderr = Some(stderr);
}
} else {
*last_miss_stderr = stderr;
}
Ok(BinOutcome::Continue)
}
const EMPTY_BINS_SETUP_ERROR: &str = "cargo build --tests produced no executable artifacts; \
ensure the workspace has at least one [[test]] target or \
a [lib]/[bin] with #[cfg(test)] tests";
pub(crate) fn probe_first<T>(
package: Option<&str>,
release: bool,
configure_cmd: impl Fn(&Path) -> Command,
on_success: impl Fn(&Path, &Output) -> Result<T, String>,
) -> Result<T, ProbeError> {
let bins = build_test_binaries(package, release).map_err(ProbeError::Setup)?;
probe_first_with_bins(&bins, configure_cmd, on_success)
}
pub(crate) fn probe_collect<T>(
package: Option<&str>,
release: bool,
configure_cmd: impl Fn(&Path) -> Command,
on_success: impl Fn(&Path, &Output) -> Result<T, String>,
) -> Result<Vec<T>, ProbeError> {
let bins = build_test_binaries(package, release).map_err(ProbeError::Setup)?;
probe_collect_with_bins(&bins, configure_cmd, on_success)
}
fn probe_first_with_bins<T>(
bins: &[PathBuf],
configure_cmd: impl Fn(&Path) -> Command,
on_success: impl Fn(&Path, &Output) -> Result<T, String>,
) -> Result<T, ProbeError> {
if bins.is_empty() {
return Err(ProbeError::Setup(EMPTY_BINS_SETUP_ERROR.to_string()));
}
let mut rejection_stderr: Option<String> = None;
let mut last_miss_stderr = String::new();
for bin in bins {
match process_bin(
bin,
&configure_cmd,
&on_success,
&mut rejection_stderr,
&mut last_miss_stderr,
)? {
BinOutcome::Success(value) => return Ok(value),
BinOutcome::Continue => continue,
}
}
Err(ProbeError::Miss(ProbeMiss {
bins_tried: bins.len(),
rejection_stderr,
last_miss_stderr,
}))
}
fn probe_collect_with_bins<T>(
bins: &[PathBuf],
configure_cmd: impl Fn(&Path) -> Command,
on_success: impl Fn(&Path, &Output) -> Result<T, String>,
) -> Result<Vec<T>, ProbeError> {
if bins.is_empty() {
return Err(ProbeError::Setup(EMPTY_BINS_SETUP_ERROR.to_string()));
}
let mut collected: Vec<T> = Vec::new();
let mut rejection_stderr: Option<String> = None;
let mut last_miss_stderr = String::new();
for bin in bins {
match process_bin(
bin,
&configure_cmd,
&on_success,
&mut rejection_stderr,
&mut last_miss_stderr,
)? {
BinOutcome::Success(value) => collected.push(value),
BinOutcome::Continue => continue,
}
}
if !collected.is_empty() {
return Ok(collected);
}
Err(ProbeError::Miss(ProbeMiss {
bins_tried: bins.len(),
rejection_stderr,
last_miss_stderr,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn fake_bin(idx: usize) -> PathBuf {
PathBuf::from(format!("/fake/bin{idx}"))
}
#[test]
fn probe_collect_with_bins_appends_in_order() {
let bins = vec![fake_bin(0), fake_bin(1)];
let configure_cmd = |_bin: &Path| Command::new("true");
let on_success =
|bin: &Path, _out: &Output| -> Result<PathBuf, String> { Ok(bin.to_path_buf()) };
let result = probe_collect_with_bins(&bins, configure_cmd, on_success)
.expect("two successes should collect");
assert_eq!(result, vec![fake_bin(0), fake_bin(1)]);
}
#[test]
fn probe_first_with_bins_short_circuits_after_first_success() {
let bins = vec![fake_bin(0), fake_bin(1), fake_bin(2)];
let spawn_count = std::cell::Cell::new(0usize);
let configure_cmd = |_bin: &Path| {
spawn_count.set(spawn_count.get() + 1);
Command::new("true")
};
let on_success = |_bin: &Path, _out: &Output| -> Result<(), String> { Ok(()) };
probe_first_with_bins(&bins, configure_cmd, on_success).expect("first success");
assert_eq!(spawn_count.get(), 1, "must not spawn after first success");
}
#[test]
fn probe_first_with_bins_empty_returns_setup_error() {
let bins: Vec<PathBuf> = vec![];
let configure_cmd = |_bin: &Path| Command::new("true");
let on_success = |_bin: &Path, _out: &Output| -> Result<(), String> { Ok(()) };
match probe_first_with_bins(&bins, configure_cmd, on_success) {
Err(ProbeError::Setup(msg)) => assert!(
msg.contains("no executable artifacts"),
"expected empty-bins diagnostic, got {msg:?}",
),
_ => panic!("expected Setup error"),
}
}
#[test]
fn probe_collect_with_bins_empty_returns_setup_error() {
let bins: Vec<PathBuf> = vec![];
let configure_cmd = |_bin: &Path| Command::new("true");
let on_success = |_bin: &Path, _out: &Output| -> Result<(), String> { Ok(()) };
match probe_collect_with_bins(&bins, configure_cmd, on_success) {
Err(ProbeError::Setup(msg)) => assert!(msg.contains("no executable artifacts")),
_ => panic!("expected Setup error"),
}
}
#[test]
fn probe_collect_with_bins_exit_2_first_wins_exit_1_overwrites() {
let bins = vec![fake_bin(0), fake_bin(1), fake_bin(2), fake_bin(3)];
let configure_cmd = |bin: &Path| {
let suffix = bin.file_name().unwrap().to_str().unwrap().to_string();
let (code, stderr) = match suffix.as_str() {
"bin0" => (2, "REJECTED_A"),
"bin1" => (2, "REJECTED_B"),
"bin2" => (1, "MISS_C"),
"bin3" => (1, "MISS_D"),
_ => unreachable!(),
};
let mut cmd = Command::new("sh");
cmd.args(["-c", &format!("printf '%s' '{stderr}' >&2; exit {code}")]);
cmd.stderr(std::process::Stdio::piped());
cmd
};
let on_success = |_bin: &Path, _out: &Output| -> Result<(), String> { Ok(()) };
match probe_collect_with_bins(&bins, configure_cmd, on_success) {
Err(ProbeError::Miss(miss)) => {
assert_eq!(miss.bins_tried, 4);
assert_eq!(
miss.rejection_stderr.as_deref(),
Some("REJECTED_A"),
"first rejection must win — REJECTED_B should be ignored",
);
assert_eq!(
miss.last_miss_stderr, "MISS_D",
"last miss must overwrite — MISS_C should be replaced",
);
}
_ => panic!("expected Miss with rejection + last-miss"),
}
}
#[test]
fn probe_collect_with_bins_all_miss_no_rejection_returns_probe_miss() {
let bins = vec![fake_bin(0), fake_bin(1), fake_bin(2)];
let configure_cmd = |_bin: &Path| Command::new("false");
let on_success = |_bin: &Path, _out: &Output| -> Result<(), String> { Ok(()) };
match probe_collect_with_bins(&bins, configure_cmd, on_success) {
Err(ProbeError::Miss(miss)) => {
assert_eq!(miss.bins_tried, 3);
assert!(
miss.rejection_stderr.is_none(),
"no exit-2 → rejection_stderr must stay None",
);
}
_ => panic!("expected Miss"),
}
}
}