diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index 89952255..01bdd6da 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -30,6 +30,7 @@ pub struct ExecArgs { pub name: Option, /// The command to execute with the exec harness + #[arg(required = true)] pub command: Vec, } @@ -125,6 +126,8 @@ pub async fn execute_config( api_client: &mut CodSpeedAPIClient, setup_cache_dir: Option<&Path>, ) -> Result<()> { + ensure_exec_commands_runnable(&config.targets)?; + let orchestrator = executor::Orchestrator::new(config, api_client).await?; if !orchestrator.is_local() { @@ -137,3 +140,77 @@ pub async fn execute_config( Ok(()) } + +/// Rejects exec targets whose executable (first token) is missing or blank before +/// the orchestrator does any setup. A blank executable otherwise surfaces only as an +/// opaque failure deep inside exec-harness. Empty *arguments* after a real executable +/// stay valid. +fn ensure_exec_commands_runnable(targets: &[executor::BenchmarkTarget]) -> Result<()> { + for target in targets { + let executor::BenchmarkTarget::Exec { command, name, .. } = target else { + continue; + }; + if command.first().is_none_or(|exe| exe.trim().is_empty()) { + let label = name.as_deref().unwrap_or(""); + bail!( + "Empty command for exec benchmark target `{label}`. Provide a program to run \ + (e.g. `codspeed exec -- [args...]`, or set a non-empty `exec` in the config)." + ); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::cli::Cli; + use crate::executor; + use clap::Parser; + + #[test] + fn exec_requires_a_command() { + // `codspeed exec` with no command must be rejected at parse time instead of + // proceeding into executor setup with an empty command. + assert!(Cli::try_parse_from(["codspeed", "exec"]).is_err()); + } + + #[test] + fn exec_accepts_a_command() { + assert!(Cli::try_parse_from(["codspeed", "exec", "echo", "hello"]).is_ok()); + } + + fn exec_target(command: &[&str], name: Option<&str>) -> executor::BenchmarkTarget { + executor::BenchmarkTarget::Exec { + command: command.iter().map(|s| s.to_string()).collect(), + name: name.map(str::to_string), + walltime_args: Default::default(), + } + } + + #[test] + fn rejects_missing_or_empty_executable() { + // Empty vec (`exec: ""`), an empty first token (`codspeed exec ''` / `exec: "''"`), + // and a whitespace-only token (`codspeed exec " "`) are all invalid. + assert!(super::ensure_exec_commands_runnable(&[exec_target(&[], Some("a"))]).is_err()); + assert!(super::ensure_exec_commands_runnable(&[exec_target(&[" "], Some("a"))]).is_err()); + let err = super::ensure_exec_commands_runnable(&[exec_target(&[""], Some("bench"))]) + .unwrap_err() + .to_string(); + assert!(err.contains("bench"), "{err}"); + } + + #[test] + fn accepts_runnable_command_with_empty_argument() { + // An empty *argument* after a real executable stays valid (e.g. `grep '' file`). + assert!(super::ensure_exec_commands_runnable(&[exec_target(&["grep", ""], None)]).is_ok()); + } + + #[test] + fn ignores_entrypoint_targets() { + let target = executor::BenchmarkTarget::Entrypoint { + command: String::new(), + name: None, + }; + assert!(super::ensure_exec_commands_runnable(&[target]).is_ok()); + } +}