Skip to content
Open
Show file tree
Hide file tree
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
19 changes: 17 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::{
exec,
local_logger::{CODSPEED_U8_COLOR_CODE, init_local_logger},
prelude::*,
project_config::ProjectConfig,
run, setup,
};
use clap::{
Expand Down Expand Up @@ -47,6 +48,12 @@ pub struct Cli {
#[arg(long, env = "CODSPEED_CONFIG_NAME", global = true)]
pub config_name: Option<String>,

/// Path to project configuration file (codspeed.yaml)
/// If provided, loads config from this path. Otherwise, searches for config files
/// in the current directory and upward to the git root.
#[arg(long, global = true)]
pub config: Option<PathBuf>,

/// The directory to use for caching installed tools
/// The runner will restore cached tools from this directory before installing them.
/// After successful installation, the runner will cache the installed tools to this directory.
Expand Down Expand Up @@ -76,6 +83,13 @@ pub async fn run() -> Result<()> {
let codspeed_config =
CodSpeedConfig::load_with_override(cli.config_name.as_deref(), cli.oauth_token.as_deref())?;
let api_client = CodSpeedAPIClient::try_from((&cli, &codspeed_config))?;

// Discover project configuration file (this may change the working directory)
let project_config = ProjectConfig::discover_and_load(
cli.config.as_deref(),
&std::env::current_dir()?
)?;

// In the context of the CI, it is likely that a ~ made its way here without being expanded by the shell
let setup_cache_dir = cli
.setup_cache_dir
Expand All @@ -92,10 +106,10 @@ pub async fn run() -> Result<()> {

match cli.command {
Commands::Run(args) => {
run::run(*args, &api_client, &codspeed_config, setup_cache_dir).await?
run::run(*args, &api_client, &codspeed_config, project_config.as_ref(), setup_cache_dir).await?
}
Commands::Exec(args) => {
exec::run(*args, &api_client, &codspeed_config, setup_cache_dir).await?
exec::run(*args, &api_client, &codspeed_config, project_config.as_ref(), setup_cache_dir).await?
}
Commands::Auth(args) => auth::run(args, &api_client, cli.config_name.as_deref()).await?,
Commands::Setup => setup::setup(setup_cache_dir).await?,
Expand All @@ -111,6 +125,7 @@ impl Cli {
api_url,
oauth_token: None,
config_name: None,
config: None,
setup_cache_dir: None,
command: Commands::Setup,
}
Expand Down
34 changes: 33 additions & 1 deletion src/exec/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use crate::binary_installer::ensure_binary_installed;
use crate::config::CodSpeedConfig;
use crate::executor;
use crate::prelude::*;
use crate::project_config::ProjectConfig;
use crate::project_config::merger::ConfigMerger;
use crate::run::uploader::UploadResult;
use clap::Args;
use std::path::Path;
Expand Down Expand Up @@ -31,13 +33,43 @@ pub struct ExecArgs {
pub command: Vec<String>,
}

impl ExecArgs {
/// Merge CLI args with project config if available
///
/// CLI arguments take precedence over config values.
pub fn merge_with_project_config(
mut self,
project_config: Option<&ProjectConfig>,
) -> Self {
if let Some(project_config) = project_config {
// Merge shared args
self.shared = ConfigMerger::merge_shared_args(
&self.shared,
project_config.options.as_ref(),
);
// Merge walltime args
self.walltime_args = ConfigMerger::merge_walltime_options(
&self.walltime_args,
project_config
.options
.as_ref()
.and_then(|o| o.walltime.as_ref()),
);
}
self
}
}

pub async fn run(
args: ExecArgs,
api_client: &CodSpeedAPIClient,
codspeed_config: &CodSpeedConfig,
project_config: Option<&ProjectConfig>,
setup_cache_dir: Option<&Path>,
) -> Result<()> {
let config = crate::executor::Config::try_from(args)?;
let merged_args = args.merge_with_project_config(project_config);

let config = crate::executor::Config::try_from(merged_args)?;
let mut execution_context = executor::ExecutionContext::try_from((config, codspeed_config))?;
debug!("config: {:#?}", execution_context.config);
let executor = executor::get_executor_from_mode(
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod instruments;
mod local_logger;
mod logger;
mod prelude;
mod project_config;
mod request_client;
mod run;
mod run_environment;
Expand Down
259 changes: 259 additions & 0 deletions src/project_config/merger.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
use crate::run::ExecAndRunSharedArgs;
use exec_harness::walltime::WalltimeExecutionArgs;

use super::{ProjectOptions, WalltimeOptions};

/// Handles merging of CLI arguments with project configuration
///
/// Implements the precedence rule: CLI > config > None
pub struct ConfigMerger;

impl ConfigMerger {
/// Merge walltime execution args with project config walltime options
///
/// CLI arguments take precedence over config values. If a CLI arg is None
/// and a config value exists, the config value is used.
pub fn merge_walltime_options(
cli: &WalltimeExecutionArgs,
config_opts: Option<&WalltimeOptions>,
) -> WalltimeExecutionArgs {
WalltimeExecutionArgs {
warmup_time: Self::merge_option(&cli.warmup_time, config_opts.and_then(|c| c.warmup_time.as_ref())),
max_time: Self::merge_option(&cli.max_time, config_opts.and_then(|c| c.max_time.as_ref())),
min_time: Self::merge_option(&cli.min_time, config_opts.and_then(|c| c.min_time.as_ref())),
max_rounds: cli.max_rounds.or(config_opts.and_then(|c| c.max_rounds)),
min_rounds: cli.min_rounds.or(config_opts.and_then(|c| c.min_rounds)),
}
}

/// Merge shared args with project config options
///
/// CLI arguments take precedence over config values.
/// Note: Some fields like upload_url, token, repository are CLI-only and not in config.
pub fn merge_shared_args(
cli: &ExecAndRunSharedArgs,
config_opts: Option<&ProjectOptions>,
) -> ExecAndRunSharedArgs {
let mut merged = cli.clone();

// Merge working_directory if not set via CLI
if merged.working_directory.is_none() {
if let Some(opts) = config_opts {
merged.working_directory = opts.working_directory.clone();
}
}

// Note: mode field has a required default value from clap, so we can't
// distinguish between "user set it" vs "default value". For now, we
// always use the CLI value. This will be addressed in a future PR
// when we make mode optional in CLI args.

merged
}

/// Helper to merge Option values with precedence: CLI > config > None
fn merge_option<T: Clone>(
cli_value: &Option<T>,
config_value: Option<&T>,
) -> Option<T> {
cli_value.clone().or_else(|| config_value.cloned())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::run::PerfRunArgs;
use crate::runner_mode::RunnerMode;

#[test]
fn test_merge_walltime_all_from_cli() {
let cli = WalltimeExecutionArgs {
warmup_time: Some("5s".to_string()),
max_time: Some("20s".to_string()),
min_time: None,
max_rounds: Some(50),
min_rounds: None,
};

let config = WalltimeOptions {
warmup_time: Some("1s".to_string()),
max_time: Some("10s".to_string()),
min_time: Some("2s".to_string()),
max_rounds: Some(100),
min_rounds: Some(10),
};

let merged = ConfigMerger::merge_walltime_options(&cli, Some(&config));

// CLI values should win
assert_eq!(merged.warmup_time, Some("5s".to_string()));
assert_eq!(merged.max_time, Some("20s".to_string()));
// Config values should be used when CLI is None
assert_eq!(merged.min_time, Some("2s".to_string()));
assert_eq!(merged.max_rounds, Some(50));
assert_eq!(merged.min_rounds, Some(10));
}

#[test]
fn test_merge_walltime_all_from_config() {
let cli = WalltimeExecutionArgs {
warmup_time: None,
max_time: None,
min_time: None,
max_rounds: None,
min_rounds: None,
};

let config = WalltimeOptions {
warmup_time: Some("3s".to_string()),
max_time: Some("15s".to_string()),
min_time: None,
max_rounds: Some(200),
min_rounds: None,
};

let merged = ConfigMerger::merge_walltime_options(&cli, Some(&config));

// All from config
assert_eq!(merged.warmup_time, Some("3s".to_string()));
assert_eq!(merged.max_time, Some("15s".to_string()));
assert_eq!(merged.min_time, None);
assert_eq!(merged.max_rounds, Some(200));
assert_eq!(merged.min_rounds, None);
}

#[test]
fn test_merge_walltime_no_config() {
let cli = WalltimeExecutionArgs {
warmup_time: Some("2s".to_string()),
max_time: None,
min_time: None,
max_rounds: Some(30),
min_rounds: None,
};

let merged = ConfigMerger::merge_walltime_options(&cli, None);

// Should be same as CLI
assert_eq!(merged.warmup_time, Some("2s".to_string()));
assert_eq!(merged.max_time, None);
assert_eq!(merged.min_time, None);
assert_eq!(merged.max_rounds, Some(30));
assert_eq!(merged.min_rounds, None);
}

#[test]
fn test_merge_shared_args_working_directory_from_cli() {
let cli = ExecAndRunSharedArgs {
upload_url: None,
token: None,
repository: None,
provider: None,
working_directory: Some("./cli-dir".to_string()),
mode: RunnerMode::Walltime,
profile_folder: None,
skip_upload: false,
skip_run: false,
skip_setup: false,
allow_empty: false,
perf_run_args: PerfRunArgs {
enable_perf: true,
perf_unwinding_mode: None,
},
};

let config = ProjectOptions {
walltime: None,
working_directory: Some("./config-dir".to_string()),
mode: Some(RunnerMode::Simulation),
};

let merged = ConfigMerger::merge_shared_args(&cli, Some(&config));

// CLI working_directory should win
assert_eq!(merged.working_directory, Some("./cli-dir".to_string()));
}

#[test]
fn test_merge_shared_args_working_directory_from_config() {
let cli = ExecAndRunSharedArgs {
upload_url: None,
token: None,
repository: None,
provider: None,
working_directory: None,
mode: RunnerMode::Walltime,
profile_folder: None,
skip_upload: false,
skip_run: false,
skip_setup: false,
allow_empty: false,
perf_run_args: PerfRunArgs {
enable_perf: true,
perf_unwinding_mode: None,
},
};

let config = ProjectOptions {
walltime: None,
working_directory: Some("./config-dir".to_string()),
mode: Some(RunnerMode::Memory),
};

let merged = ConfigMerger::merge_shared_args(&cli, Some(&config));

// Config working_directory should be used
assert_eq!(merged.working_directory, Some("./config-dir".to_string()));
// Mode stays as CLI default (can't override due to clap default)
assert_eq!(merged.mode, RunnerMode::Walltime);
}

#[test]
fn test_merge_shared_args_no_config() {
let cli = ExecAndRunSharedArgs {
upload_url: None,
token: None,
repository: None,
provider: None,
working_directory: Some("./dir".to_string()),
mode: RunnerMode::Simulation,
profile_folder: None,
skip_upload: false,
skip_run: false,
skip_setup: false,
allow_empty: false,
perf_run_args: PerfRunArgs {
enable_perf: false,
perf_unwinding_mode: None,
},
};

let merged = ConfigMerger::merge_shared_args(&cli, None);

// Should be identical to CLI
assert_eq!(merged.working_directory, Some("./dir".to_string()));
assert_eq!(merged.mode, RunnerMode::Simulation);
}

#[test]
fn test_merge_option_helper() {
// CLI value wins
let cli_val = Some("cli".to_string());
let config_val = Some("config".to_string());
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
assert_eq!(result, Some("cli".to_string()));

// Config value used when CLI is None
let cli_val: Option<String> = None;
let config_val = Some("config".to_string());
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
assert_eq!(result, Some("config".to_string()));

// Both None
let cli_val: Option<String> = None;
let config_val: Option<String> = None;
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
assert_eq!(result, None);
}
}
Loading
Loading