diff --git a/src/app.rs b/src/app.rs index f840efb5..dbef88e8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,6 +7,7 @@ use crate::{ exec, local_logger::{CODSPEED_U8_COLOR_CODE, init_local_logger}, prelude::*, + project_config::ProjectConfig, run, setup, }; use clap::{ @@ -47,6 +48,12 @@ pub struct Cli { #[arg(long, env = "CODSPEED_CONFIG_NAME", global = true)] pub config_name: Option, + /// 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, + /// 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. @@ -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 @@ -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?, @@ -111,6 +125,7 @@ impl Cli { api_url, oauth_token: None, config_name: None, + config: None, setup_cache_dir: None, command: Commands::Setup, } diff --git a/src/exec/mod.rs b/src/exec/mod.rs index 96c41833..51244488 100644 --- a/src/exec/mod.rs +++ b/src/exec/mod.rs @@ -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; @@ -31,13 +33,43 @@ pub struct ExecArgs { pub command: Vec, } +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( diff --git a/src/main.rs b/src/main.rs index f2d6bc4b..8ff00e69 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ mod instruments; mod local_logger; mod logger; mod prelude; +mod project_config; mod request_client; mod run; mod run_environment; diff --git a/src/project_config/merger.rs b/src/project_config/merger.rs new file mode 100644 index 00000000..4d0b0e89 --- /dev/null +++ b/src/project_config/merger.rs @@ -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( + cli_value: &Option, + config_value: Option<&T>, + ) -> Option { + 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 = 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 = None; + let config_val: Option = None; + let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref()); + assert_eq!(result, None); + } +} diff --git a/src/project_config/mod.rs b/src/project_config/mod.rs new file mode 100644 index 00000000..b009c649 --- /dev/null +++ b/src/project_config/mod.rs @@ -0,0 +1,456 @@ +use crate::prelude::*; +use crate::runner_mode::RunnerMode; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub mod merger; + +/// Project-level configuration from codspeed.yaml file +/// +/// This configuration provides default options for the run and exec commands. +/// CLI arguments always take precedence over config file values. +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ProjectConfig { + /// Default options to apply to all benchmark runs + pub options: Option, +} + +/// Root-level options that apply to all benchmark runs unless overridden by CLI +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct ProjectOptions { + /// Walltime execution configuration + pub walltime: Option, + /// Working directory where commands will be executed (relative to config file) + pub working_directory: Option, + /// Runner mode (walltime, memory, or simulation) + pub mode: Option, +} + +/// Walltime execution options matching WalltimeExecutionArgs structure +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub struct WalltimeOptions { + /// Duration of warmup phase (e.g., "1s", "500ms") + pub warmup_time: Option, + /// Maximum total execution time + pub max_time: Option, + /// Minimum total execution time + pub min_time: Option, + /// Maximum number of rounds + pub max_rounds: Option, + /// Minimum number of rounds + pub min_rounds: Option, +} + +/// Config file names in priority order +const CONFIG_FILENAMES: &[&str] = &[ + "codspeed.yaml", + "codspeed.yml", + ".codspeed.yaml", + ".codspeed.yml", +]; + +impl ProjectConfig { + /// Discover and load project configuration file + /// + /// # Search Strategy + /// 1. If `config_path_override` is provided, load from that path only (error if not found) + /// 2. Otherwise, search for config files in current directory and upward to git root + /// 3. Try filenames in priority order: codspeed.yaml, codspeed.yml, .codspeed.yaml, .codspeed.yml + /// 4. If a config is found in a parent directory, changes the working directory to that location + /// + /// # Arguments + /// * `config_path_override` - Explicit path to config file (from --config flag) + /// * `current_dir` - Directory to start searching from + /// + /// # Returns + /// * `Ok(Some(config))` - Config found and loaded successfully + /// * `Ok(None)` - No config file found + /// * `Err(_)` - Error loading or parsing config + pub fn discover_and_load( + config_path_override: Option<&Path>, + current_dir: &Path, + ) -> Result> { + // Case 1: Explicit --config path provided + if let Some(config_path) = config_path_override { + let config = Self::load_from_path(config_path) + .with_context(|| format!("Failed to load config from {}", config_path.display()))?; + let canonical_path = config_path + .canonicalize() + .unwrap_or_else(|_| config_path.to_path_buf()); + + // Change working directory if config was found in a different directory + Self::change_to_config_directory(&canonical_path, current_dir)?; + + return Ok(Some(config)); + } + + // Case 2: Search for config files + let search_dirs = Self::get_search_directories(current_dir)?; + + for dir in search_dirs { + for filename in CONFIG_FILENAMES { + let candidate_path = dir.join(filename); + if candidate_path.exists() { + debug!("Found config file at {}", candidate_path.display()); + let config = Self::load_from_path(&candidate_path)?; + let canonical_path = candidate_path.canonicalize().unwrap_or(candidate_path); + + // Change working directory if config was found in a different directory + Self::change_to_config_directory(&canonical_path, current_dir)?; + + return Ok(Some(config)); + } + } + } + + // No config found - this is OK + Ok(None) + } + + /// Get list of directories to search for config files + /// + /// Returns directories from current_dir upward to git root (if in a git repo) + fn get_search_directories(current_dir: &Path) -> Result> { + let mut dirs = vec![current_dir.to_path_buf()]; + + // Try to find git repository root + if let Some(git_root) = crate::run::helpers::find_repository_root(current_dir) { + // Add parent directories up to git root + let mut dir = current_dir.to_path_buf(); + while let Some(parent) = dir.parent() { + if parent == git_root { + if !dirs.contains(&git_root) { + dirs.push(git_root.clone()); + } + break; + } + if !dirs.contains(&parent.to_path_buf()) { + dirs.push(parent.to_path_buf()); + } + dir = parent.to_path_buf(); + } + } + + Ok(dirs) + } + + /// Change working directory to the directory containing the config file + fn change_to_config_directory(config_path: &Path, original_dir: &Path) -> Result<()> { + let config_dir = config_path + .parent() + .context("Config file has no parent directory")?; + + if config_dir != original_dir { + std::env::set_current_dir(config_dir)?; + debug!( + "Changed working directory from {} to {}", + original_dir.display(), + config_dir.display() + ); + } + + Ok(()) + } + + /// Load and parse config from a specific path + fn load_from_path(path: &Path) -> Result { + let config_content = fs::read(path) + .with_context(|| format!("Failed to read config file at {}", path.display()))?; + + let config: Self = serde_yaml::from_slice(&config_content).with_context(|| { + format!( + "Failed to parse CodSpeed project config at {}", + path.display() + ) + })?; + + // Validate the config + config.validate()?; + + Ok(config) + } + + /// Validate the configuration + /// + /// Checks for invalid combinations of options, particularly in walltime config + fn validate(&self) -> Result<()> { + if let Some(options) = &self.options { + if let Some(walltime) = &options.walltime { + Self::validate_walltime_options(walltime, "root options")?; + } + } + Ok(()) + } + + /// Validate walltime options for conflicting constraints + fn validate_walltime_options(opts: &WalltimeOptions, context: &str) -> Result<()> { + // Check for explicitly forbidden combinations + if opts.min_time.is_some() && opts.max_rounds.is_some() { + bail!( + "Invalid walltime configuration in {context}: cannot use both min_time and max_rounds" + ); + } + + if opts.max_time.is_some() && opts.min_rounds.is_some() { + bail!( + "Invalid walltime configuration in {context}: cannot use both max_time and min_rounds" + ); + } + + // Note: We don't parse durations here or check min < max relationships + // That validation happens later in WalltimeExecutionArgs::try_from(ExecutionOptions) + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn test_deserialize_minimal_config() { + let yaml = r#" +options: + walltime: + warmup-time: 1s +"#; + let config: ProjectConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.options.is_some()); + let options = config.options.unwrap(); + assert!(options.walltime.is_some()); + assert_eq!( + options.walltime.unwrap().warmup_time, + Some("1s".to_string()) + ); + } + + #[test] + fn test_deserialize_full_walltime_config() { + let yaml = r#" +options: + walltime: + warmup-time: 2s + max-time: 10s + min-time: 1s + max-rounds: 100 + min-rounds: 10 + working-directory: ./bench + mode: walltime +"#; + let config: ProjectConfig = serde_yaml::from_str(yaml).unwrap(); + let options = config.options.unwrap(); + let walltime = options.walltime.unwrap(); + + assert_eq!(walltime.warmup_time, Some("2s".to_string())); + assert_eq!(walltime.max_time, Some("10s".to_string())); + assert_eq!(walltime.min_time, Some("1s".to_string())); + assert_eq!(walltime.max_rounds, Some(100)); + assert_eq!(walltime.min_rounds, Some(10)); + assert_eq!(options.working_directory, Some("./bench".to_string())); + assert_eq!(options.mode, Some(RunnerMode::Walltime)); + } + + #[test] + fn test_deserialize_empty_config() { + let yaml = r#"{}"#; + let config: ProjectConfig = serde_yaml::from_str(yaml).unwrap(); + assert!(config.options.is_none()); + } + + #[test] + fn test_validate_conflicting_min_time_max_rounds() { + let config = ProjectConfig { + options: Some(ProjectOptions { + walltime: Some(WalltimeOptions { + warmup_time: None, + max_time: None, + min_time: Some("1s".to_string()), + max_rounds: Some(10), + min_rounds: None, + }), + working_directory: None, + mode: None, + }), + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot use both min_time and max_rounds") + ); + } + + #[test] + fn test_validate_conflicting_max_time_min_rounds() { + let config = ProjectConfig { + options: Some(ProjectOptions { + walltime: Some(WalltimeOptions { + warmup_time: None, + max_time: Some("10s".to_string()), + min_time: None, + max_rounds: None, + min_rounds: Some(5), + }), + working_directory: None, + mode: None, + }), + }; + + let result = config.validate(); + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot use both max_time and min_rounds") + ); + } + + #[test] + fn test_validate_valid_config() { + let config = ProjectConfig { + options: Some(ProjectOptions { + walltime: Some(WalltimeOptions { + warmup_time: Some("1s".to_string()), + max_time: Some("10s".to_string()), + min_time: Some("2s".to_string()), + max_rounds: None, + min_rounds: None, + }), + working_directory: Some("./bench".to_string()), + mode: Some(RunnerMode::Walltime), + }), + }; + + assert!(config.validate().is_ok()); + } + + #[test] + fn test_load_from_path() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("codspeed.yaml"); + + fs::write( + &config_path, + r#" +options: + walltime: + warmup-time: 5s +"#, + ) + .unwrap(); + + let config = ProjectConfig::load_from_path(&config_path).unwrap(); + assert!(config.options.is_some()); + } + + #[test] + fn test_load_from_path_invalid_yaml() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("codspeed.yaml"); + + fs::write(&config_path, "invalid: yaml: content:").unwrap(); + + let result = ProjectConfig::load_from_path(&config_path); + assert!(result.is_err()); + } + + #[test] + fn test_discover_with_explicit_path() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("my-config.yaml"); + + fs::write( + &config_path, + r#" +options: + walltime: + warmup-time: 3s +"#, + ) + .unwrap(); + + let config = ProjectConfig::discover_and_load(Some(&config_path), temp_dir.path()).unwrap(); + + assert!(config.is_some()); + let config = config.unwrap(); + assert!(config.options.is_some()); + } + + #[test] + fn test_discover_with_explicit_path_not_found() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("missing.yaml"); + + let result = ProjectConfig::discover_and_load(Some(&config_path), temp_dir.path()); + assert!(result.is_err()); + } + + #[test] + fn test_discover_finds_codspeed_yaml() { + let temp_dir = TempDir::new().unwrap(); + let config_path = temp_dir.path().join("codspeed.yaml"); + + fs::write( + &config_path, + r#" +options: + walltime: + warmup-time: 2s +"#, + ) + .unwrap(); + + let config = ProjectConfig::discover_and_load(None, temp_dir.path()).unwrap(); + + assert!(config.is_some()); + } + + #[test] + fn test_discover_priority_yaml_over_yml() { + let temp_dir = TempDir::new().unwrap(); + + // Create both .yaml and .yml files + fs::write( + temp_dir.path().join("codspeed.yaml"), + r#" +options: + walltime: + warmup-time: 1s +"#, + ) + .unwrap(); + + fs::write( + temp_dir.path().join("codspeed.yml"), + r#" +options: + walltime: + warmup-time: 2s +"#, + ) + .unwrap(); + + let config = ProjectConfig::discover_and_load(None, temp_dir.path()).unwrap(); + + assert!(config.is_some()); + // Note: We can no longer verify which file was loaded since we don't return the path + // The priority is still enforced but not testable without checking the filesystem + } + + #[test] + fn test_discover_no_config_found() { + let temp_dir = TempDir::new().unwrap(); + let config = ProjectConfig::discover_and_load(None, temp_dir.path()).unwrap(); + assert!(config.is_none()); + } +} diff --git a/src/run/mod.rs b/src/run/mod.rs index c30bb36d..ba587f6b 100644 --- a/src/run/mod.rs +++ b/src/run/mod.rs @@ -4,6 +4,8 @@ use crate::config::CodSpeedConfig; use crate::executor; use crate::executor::Config; use crate::prelude::*; +use crate::project_config::ProjectConfig; +use crate::project_config::merger::ConfigMerger; use crate::run::uploader::UploadResult; use crate::run_environment::interfaces::RepositoryProvider; use crate::runner_mode::RunnerMode; @@ -144,6 +146,24 @@ pub struct RunArgs { pub command: Vec, } +impl RunArgs { + /// 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 { + self.shared = ConfigMerger::merge_shared_args( + &self.shared, + project_config.options.as_ref(), + ); + } + self + } +} + #[derive(ValueEnum, Clone, Debug, PartialEq)] pub enum MessageFormat { Json, @@ -183,10 +203,14 @@ pub async fn run( args: RunArgs, api_client: &CodSpeedAPIClient, codspeed_config: &CodSpeedConfig, + project_config: Option<&ProjectConfig>, setup_cache_dir: Option<&Path>, ) -> Result<()> { let output_json = args.message_format == Some(MessageFormat::Json); - let config = Config::try_from(args)?; + + let merged_args = args.merge_with_project_config(project_config); + + let config = Config::try_from(merged_args)?; // Create execution context let mut execution_context = executor::ExecutionContext::try_from((config, codspeed_config))?; diff --git a/src/runner_mode.rs b/src/runner_mode.rs index af143f87..79fe31ee 100644 --- a/src/runner_mode.rs +++ b/src/runner_mode.rs @@ -1,7 +1,7 @@ use clap::ValueEnum; -use serde::Serialize; +use serde::{Deserialize, Serialize}; -#[derive(ValueEnum, Clone, Debug, Serialize, PartialEq)] +#[derive(ValueEnum, Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum RunnerMode { #[deprecated(note = "Use `RunnerMode::Simulation` instead")]