Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/cli/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub struct ExecArgs {
pub name: Option<String>,

/// The command to execute with the exec harness
#[arg(required = true)]
pub command: Vec<String>,
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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("<unnamed>");
bail!(
"Empty command for exec benchmark target `{label}`. Provide a program to run \
(e.g. `codspeed exec -- <program> [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());
}
}