use std::process::Command;
use clap::ValueEnum;
use serde::Serialize;
use crate::error::{RailError, RailResult};
fn cargo_command() -> Command {
Command::new("cargo")
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, ValueEnum)]
#[serde(rename_all = "snake_case")]
pub enum TestRunnerPreference {
#[default]
Auto,
Cargo,
Nextest,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TestCommandArgs {
pub cargo: Vec<String>,
pub nextest: Vec<String>,
pub filter: Option<String>,
pub harness: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TestRunner {
CargoTest,
Nextest,
}
impl TestRunner {
pub fn name(&self) -> &str {
match self {
Self::CargoTest => "cargo test",
Self::Nextest => "cargo nextest",
}
}
pub fn is_available(&self) -> bool {
match self {
Self::CargoTest => true, Self::Nextest => cargo_command()
.arg("nextest")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false),
}
}
pub fn build_command(&self, packages: &[String], args: &TestCommandArgs) -> RailResult<Command> {
let mut cmd = cargo_command();
match self {
Self::CargoTest => {
if !args.nextest.is_empty() {
return Err(backend_argument_error("nextest", self.name()));
}
cmd.arg("test");
for pkg in packages {
cmd.arg("-p").arg(pkg);
}
cmd.args(&args.cargo);
if let Some(filter) = &args.filter {
cmd.arg(filter);
}
if !args.harness.is_empty() {
cmd.arg("--");
cmd.args(&args.harness);
}
}
Self::Nextest => {
if !args.cargo.is_empty() {
return Err(backend_argument_error("Cargo test", self.name()));
}
cmd.arg("nextest").arg("run");
for pkg in packages {
cmd.arg("-p").arg(pkg);
}
cmd.args(&args.nextest);
if let Some(filter) = &args.filter {
cmd.arg(filter);
}
if !args.harness.is_empty() {
cmd.arg("--");
cmd.args(&args.harness);
}
}
}
Ok(cmd)
}
}
fn backend_argument_error(argument_backend: &str, selected_backend: &str) -> RailError {
RailError::with_help(
format!("{} options cannot be used with {}", argument_backend, selected_backend),
"select the matching --test-runner or remove the backend-specific options",
)
}
pub fn select_runner(preference: TestRunnerPreference, args: &TestCommandArgs) -> RailResult<TestRunner> {
resolve_runner(preference, args, TestRunner::Nextest.is_available())
}
fn resolve_runner(
preference: TestRunnerPreference,
args: &TestCommandArgs,
nextest_available: bool,
) -> RailResult<TestRunner> {
if !args.cargo.is_empty() && !args.nextest.is_empty() {
return Err(RailError::with_help(
"Cargo test and nextest options cannot be combined",
"select one backend and pass options only for that backend",
));
}
match preference {
TestRunnerPreference::Cargo => {
if !args.nextest.is_empty() {
return Err(backend_argument_error("nextest", TestRunner::CargoTest.name()));
}
Ok(TestRunner::CargoTest)
}
TestRunnerPreference::Nextest => {
if !args.cargo.is_empty() {
return Err(backend_argument_error("Cargo test", TestRunner::Nextest.name()));
}
require_nextest(nextest_available)
}
TestRunnerPreference::Auto if !args.cargo.is_empty() => Ok(TestRunner::CargoTest),
TestRunnerPreference::Auto if !args.nextest.is_empty() => require_nextest(nextest_available),
TestRunnerPreference::Auto if nextest_available => Ok(TestRunner::Nextest),
TestRunnerPreference::Auto => Ok(TestRunner::CargoTest),
}
}
fn require_nextest(available: bool) -> RailResult<TestRunner> {
if available {
Ok(TestRunner::Nextest)
} else {
Err(RailError::with_help(
"cargo-nextest is required by the selected test options but is not available",
"install cargo-nextest or select --test-runner cargo",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cargo_test_runner_always_available() {
let runner = TestRunner::CargoTest;
assert!(runner.is_available());
assert_eq!(runner.name(), "cargo test");
}
#[test]
fn test_cargo_test_command_building() {
let runner = TestRunner::CargoTest;
let packages = vec!["crate-a".to_string(), "crate-b".to_string()];
let args = TestCommandArgs {
cargo: vec!["--all-features".to_string()],
filter: Some("selected_test".to_string()),
harness: vec!["--nocapture".to_string(), "--test-threads=1".to_string()],
..TestCommandArgs::default()
};
let cmd = runner
.build_command(&packages, &args)
.expect("Cargo arguments should render");
assert_eq!(cmd.get_program(), "cargo");
assert_eq!(
command_args(&cmd),
[
"test",
"-p",
"crate-a",
"-p",
"crate-b",
"--all-features",
"selected_test",
"--",
"--nocapture",
"--test-threads=1",
]
);
}
#[test]
fn test_nextest_runner_name() {
let runner = TestRunner::Nextest;
assert_eq!(runner.name(), "cargo nextest");
}
#[test]
fn test_nextest_command_building() {
let runner = TestRunner::Nextest;
let packages = vec!["crate-a".to_string()];
let args = TestCommandArgs {
nextest: vec!["-P".to_string(), "commit".to_string()],
filter: Some("selected_test".to_string()),
harness: vec!["--nocapture".to_string()],
..TestCommandArgs::default()
};
let cmd = runner
.build_command(&packages, &args)
.expect("nextest arguments should render");
assert_eq!(cmd.get_program(), "cargo");
assert_eq!(
command_args(&cmd),
[
"nextest",
"run",
"-p",
"crate-a",
"-P",
"commit",
"selected_test",
"--",
"--nocapture",
]
);
}
#[test]
fn test_auto_selection_never_reinterprets_backend_options() {
let cargo_args = TestCommandArgs {
cargo: vec!["--all-features".to_string()],
..TestCommandArgs::default()
};
let nextest_args = TestCommandArgs {
nextest: vec!["-P".to_string(), "commit".to_string()],
..TestCommandArgs::default()
};
assert_eq!(
resolve_runner(TestRunnerPreference::Auto, &cargo_args, true).expect("Cargo options select Cargo"),
TestRunner::CargoTest
);
assert_eq!(
resolve_runner(TestRunnerPreference::Auto, &nextest_args, true).expect("nextest is available"),
TestRunner::Nextest
);
let error = resolve_runner(TestRunnerPreference::Auto, &nextest_args, false)
.expect_err("nextest options must not fall back to Cargo");
assert!(error.to_string().contains("cargo-nextest is required"));
}
#[test]
fn test_selection_rejects_mixed_or_mismatched_backend_options() {
let mixed = TestCommandArgs {
cargo: vec!["--all-features".to_string()],
nextest: vec!["-P".to_string(), "commit".to_string()],
..TestCommandArgs::default()
};
let nextest_only = TestCommandArgs {
nextest: vec!["-P".to_string(), "commit".to_string()],
..TestCommandArgs::default()
};
let mixed_error =
resolve_runner(TestRunnerPreference::Auto, &mixed, true).expect_err("mixed backend options must be rejected");
assert!(mixed_error.to_string().contains("cannot be combined"));
let mismatch_error = resolve_runner(TestRunnerPreference::Cargo, &nextest_only, true)
.expect_err("nextest options must not be rendered by Cargo");
assert!(
mismatch_error
.to_string()
.contains("nextest options cannot be used with cargo test")
);
}
fn command_args(command: &Command) -> Vec<String> {
command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect()
}
}