Skip to content

Commit 421a2c7

Browse files
feat: add yaml project config discovery and loading
1 parent 0bde17f commit 421a2c7

7 files changed

Lines changed: 793 additions & 6 deletions

File tree

src/app.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::{
77
exec,
88
local_logger::{CODSPEED_U8_COLOR_CODE, init_local_logger},
99
prelude::*,
10+
project_config::ProjectConfig,
1011
run, setup,
1112
};
1213
use clap::{
@@ -47,6 +48,12 @@ pub struct Cli {
4748
#[arg(long, env = "CODSPEED_CONFIG_NAME", global = true)]
4849
pub config_name: Option<String>,
4950

51+
/// Path to project configuration file (codspeed.yaml)
52+
/// If provided, loads config from this path. Otherwise, searches for config files
53+
/// in the current directory and upward to the git root.
54+
#[arg(long, global = true)]
55+
pub config: Option<PathBuf>,
56+
5057
/// The directory to use for caching installed tools
5158
/// The runner will restore cached tools from this directory before installing them.
5259
/// After successful installation, the runner will cache the installed tools to this directory.
@@ -76,6 +83,13 @@ pub async fn run() -> Result<()> {
7683
let codspeed_config =
7784
CodSpeedConfig::load_with_override(cli.config_name.as_deref(), cli.oauth_token.as_deref())?;
7885
let api_client = CodSpeedAPIClient::try_from((&cli, &codspeed_config))?;
86+
87+
// Discover project configuration file (this may change the working directory)
88+
let project_config = ProjectConfig::discover_and_load(
89+
cli.config.as_deref(),
90+
&std::env::current_dir()?
91+
)?;
92+
7993
// In the context of the CI, it is likely that a ~ made its way here without being expanded by the shell
8094
let setup_cache_dir = cli
8195
.setup_cache_dir
@@ -92,10 +106,10 @@ pub async fn run() -> Result<()> {
92106

93107
match cli.command {
94108
Commands::Run(args) => {
95-
run::run(*args, &api_client, &codspeed_config, setup_cache_dir).await?
109+
run::run(*args, &api_client, &codspeed_config, project_config.as_ref(), setup_cache_dir).await?
96110
}
97111
Commands::Exec(args) => {
98-
exec::run(*args, &api_client, &codspeed_config, setup_cache_dir).await?
112+
exec::run(*args, &api_client, &codspeed_config, project_config.as_ref(), setup_cache_dir).await?
99113
}
100114
Commands::Auth(args) => auth::run(args, &api_client, cli.config_name.as_deref()).await?,
101115
Commands::Setup => setup::setup(setup_cache_dir).await?,
@@ -111,6 +125,7 @@ impl Cli {
111125
api_url,
112126
oauth_token: None,
113127
config_name: None,
128+
config: None,
114129
setup_cache_dir: None,
115130
command: Commands::Setup,
116131
}

src/exec/mod.rs

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use crate::binary_installer::ensure_binary_installed;
33
use crate::config::CodSpeedConfig;
44
use crate::executor;
55
use crate::prelude::*;
6+
use crate::project_config::ProjectConfig;
7+
use crate::project_config::merger::ConfigMerger;
68
use crate::run::uploader::UploadResult;
79
use clap::Args;
810
use std::path::Path;
@@ -31,13 +33,43 @@ pub struct ExecArgs {
3133
pub command: Vec<String>,
3234
}
3335

36+
impl ExecArgs {
37+
/// Merge CLI args with project config if available
38+
///
39+
/// CLI arguments take precedence over config values.
40+
pub fn merge_with_project_config(
41+
mut self,
42+
project_config: Option<&ProjectConfig>,
43+
) -> Self {
44+
if let Some(project_config) = project_config {
45+
// Merge shared args
46+
self.shared = ConfigMerger::merge_shared_args(
47+
&self.shared,
48+
project_config.options.as_ref(),
49+
);
50+
// Merge walltime args
51+
self.walltime_args = ConfigMerger::merge_walltime_options(
52+
&self.walltime_args,
53+
project_config
54+
.options
55+
.as_ref()
56+
.and_then(|o| o.walltime.as_ref()),
57+
);
58+
}
59+
self
60+
}
61+
}
62+
3463
pub async fn run(
3564
args: ExecArgs,
3665
api_client: &CodSpeedAPIClient,
3766
codspeed_config: &CodSpeedConfig,
67+
project_config: Option<&ProjectConfig>,
3868
setup_cache_dir: Option<&Path>,
3969
) -> Result<()> {
40-
let config = crate::executor::Config::try_from(args)?;
70+
let merged_args = args.merge_with_project_config(project_config);
71+
72+
let config = crate::executor::Config::try_from(merged_args)?;
4173
let mut execution_context = executor::ExecutionContext::try_from((config, codspeed_config))?;
4274
debug!("config: {:#?}", execution_context.config);
4375
let executor = executor::get_executor_from_mode(

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod instruments;
99
mod local_logger;
1010
mod logger;
1111
mod prelude;
12+
mod project_config;
1213
mod request_client;
1314
mod run;
1415
mod run_environment;

src/project_config/merger.rs

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
use crate::run::ExecAndRunSharedArgs;
2+
use exec_harness::walltime::WalltimeExecutionArgs;
3+
4+
use super::{ProjectOptions, WalltimeOptions};
5+
6+
/// Handles merging of CLI arguments with project configuration
7+
///
8+
/// Implements the precedence rule: CLI > config > None
9+
pub struct ConfigMerger;
10+
11+
impl ConfigMerger {
12+
/// Merge walltime execution args with project config walltime options
13+
///
14+
/// CLI arguments take precedence over config values. If a CLI arg is None
15+
/// and a config value exists, the config value is used.
16+
pub fn merge_walltime_options(
17+
cli: &WalltimeExecutionArgs,
18+
config_opts: Option<&WalltimeOptions>,
19+
) -> WalltimeExecutionArgs {
20+
WalltimeExecutionArgs {
21+
warmup_time: Self::merge_option(&cli.warmup_time, config_opts.and_then(|c| c.warmup_time.as_ref())),
22+
max_time: Self::merge_option(&cli.max_time, config_opts.and_then(|c| c.max_time.as_ref())),
23+
min_time: Self::merge_option(&cli.min_time, config_opts.and_then(|c| c.min_time.as_ref())),
24+
max_rounds: cli.max_rounds.or(config_opts.and_then(|c| c.max_rounds)),
25+
min_rounds: cli.min_rounds.or(config_opts.and_then(|c| c.min_rounds)),
26+
}
27+
}
28+
29+
/// Merge shared args with project config options
30+
///
31+
/// CLI arguments take precedence over config values.
32+
/// Note: Some fields like upload_url, token, repository are CLI-only and not in config.
33+
pub fn merge_shared_args(
34+
cli: &ExecAndRunSharedArgs,
35+
config_opts: Option<&ProjectOptions>,
36+
) -> ExecAndRunSharedArgs {
37+
let mut merged = cli.clone();
38+
39+
// Merge working_directory if not set via CLI
40+
if merged.working_directory.is_none() {
41+
if let Some(opts) = config_opts {
42+
merged.working_directory = opts.working_directory.clone();
43+
}
44+
}
45+
46+
// Note: mode field has a required default value from clap, so we can't
47+
// distinguish between "user set it" vs "default value". For now, we
48+
// always use the CLI value. This will be addressed in a future PR
49+
// when we make mode optional in CLI args.
50+
51+
merged
52+
}
53+
54+
/// Helper to merge Option values with precedence: CLI > config > None
55+
fn merge_option<T: Clone>(
56+
cli_value: &Option<T>,
57+
config_value: Option<&T>,
58+
) -> Option<T> {
59+
cli_value.clone().or_else(|| config_value.cloned())
60+
}
61+
}
62+
63+
#[cfg(test)]
64+
mod tests {
65+
use super::*;
66+
use crate::run::PerfRunArgs;
67+
use crate::runner_mode::RunnerMode;
68+
69+
#[test]
70+
fn test_merge_walltime_all_from_cli() {
71+
let cli = WalltimeExecutionArgs {
72+
warmup_time: Some("5s".to_string()),
73+
max_time: Some("20s".to_string()),
74+
min_time: None,
75+
max_rounds: Some(50),
76+
min_rounds: None,
77+
};
78+
79+
let config = WalltimeOptions {
80+
warmup_time: Some("1s".to_string()),
81+
max_time: Some("10s".to_string()),
82+
min_time: Some("2s".to_string()),
83+
max_rounds: Some(100),
84+
min_rounds: Some(10),
85+
};
86+
87+
let merged = ConfigMerger::merge_walltime_options(&cli, Some(&config));
88+
89+
// CLI values should win
90+
assert_eq!(merged.warmup_time, Some("5s".to_string()));
91+
assert_eq!(merged.max_time, Some("20s".to_string()));
92+
// Config values should be used when CLI is None
93+
assert_eq!(merged.min_time, Some("2s".to_string()));
94+
assert_eq!(merged.max_rounds, Some(50));
95+
assert_eq!(merged.min_rounds, Some(10));
96+
}
97+
98+
#[test]
99+
fn test_merge_walltime_all_from_config() {
100+
let cli = WalltimeExecutionArgs {
101+
warmup_time: None,
102+
max_time: None,
103+
min_time: None,
104+
max_rounds: None,
105+
min_rounds: None,
106+
};
107+
108+
let config = WalltimeOptions {
109+
warmup_time: Some("3s".to_string()),
110+
max_time: Some("15s".to_string()),
111+
min_time: None,
112+
max_rounds: Some(200),
113+
min_rounds: None,
114+
};
115+
116+
let merged = ConfigMerger::merge_walltime_options(&cli, Some(&config));
117+
118+
// All from config
119+
assert_eq!(merged.warmup_time, Some("3s".to_string()));
120+
assert_eq!(merged.max_time, Some("15s".to_string()));
121+
assert_eq!(merged.min_time, None);
122+
assert_eq!(merged.max_rounds, Some(200));
123+
assert_eq!(merged.min_rounds, None);
124+
}
125+
126+
#[test]
127+
fn test_merge_walltime_no_config() {
128+
let cli = WalltimeExecutionArgs {
129+
warmup_time: Some("2s".to_string()),
130+
max_time: None,
131+
min_time: None,
132+
max_rounds: Some(30),
133+
min_rounds: None,
134+
};
135+
136+
let merged = ConfigMerger::merge_walltime_options(&cli, None);
137+
138+
// Should be same as CLI
139+
assert_eq!(merged.warmup_time, Some("2s".to_string()));
140+
assert_eq!(merged.max_time, None);
141+
assert_eq!(merged.min_time, None);
142+
assert_eq!(merged.max_rounds, Some(30));
143+
assert_eq!(merged.min_rounds, None);
144+
}
145+
146+
#[test]
147+
fn test_merge_shared_args_working_directory_from_cli() {
148+
let cli = ExecAndRunSharedArgs {
149+
upload_url: None,
150+
token: None,
151+
repository: None,
152+
provider: None,
153+
working_directory: Some("./cli-dir".to_string()),
154+
mode: RunnerMode::Walltime,
155+
profile_folder: None,
156+
skip_upload: false,
157+
skip_run: false,
158+
skip_setup: false,
159+
allow_empty: false,
160+
perf_run_args: PerfRunArgs {
161+
enable_perf: true,
162+
perf_unwinding_mode: None,
163+
},
164+
};
165+
166+
let config = ProjectOptions {
167+
walltime: None,
168+
working_directory: Some("./config-dir".to_string()),
169+
mode: Some(RunnerMode::Simulation),
170+
};
171+
172+
let merged = ConfigMerger::merge_shared_args(&cli, Some(&config));
173+
174+
// CLI working_directory should win
175+
assert_eq!(merged.working_directory, Some("./cli-dir".to_string()));
176+
}
177+
178+
#[test]
179+
fn test_merge_shared_args_working_directory_from_config() {
180+
let cli = ExecAndRunSharedArgs {
181+
upload_url: None,
182+
token: None,
183+
repository: None,
184+
provider: None,
185+
working_directory: None,
186+
mode: RunnerMode::Walltime,
187+
profile_folder: None,
188+
skip_upload: false,
189+
skip_run: false,
190+
skip_setup: false,
191+
allow_empty: false,
192+
perf_run_args: PerfRunArgs {
193+
enable_perf: true,
194+
perf_unwinding_mode: None,
195+
},
196+
};
197+
198+
let config = ProjectOptions {
199+
walltime: None,
200+
working_directory: Some("./config-dir".to_string()),
201+
mode: Some(RunnerMode::Memory),
202+
};
203+
204+
let merged = ConfigMerger::merge_shared_args(&cli, Some(&config));
205+
206+
// Config working_directory should be used
207+
assert_eq!(merged.working_directory, Some("./config-dir".to_string()));
208+
// Mode stays as CLI default (can't override due to clap default)
209+
assert_eq!(merged.mode, RunnerMode::Walltime);
210+
}
211+
212+
#[test]
213+
fn test_merge_shared_args_no_config() {
214+
let cli = ExecAndRunSharedArgs {
215+
upload_url: None,
216+
token: None,
217+
repository: None,
218+
provider: None,
219+
working_directory: Some("./dir".to_string()),
220+
mode: RunnerMode::Simulation,
221+
profile_folder: None,
222+
skip_upload: false,
223+
skip_run: false,
224+
skip_setup: false,
225+
allow_empty: false,
226+
perf_run_args: PerfRunArgs {
227+
enable_perf: false,
228+
perf_unwinding_mode: None,
229+
},
230+
};
231+
232+
let merged = ConfigMerger::merge_shared_args(&cli, None);
233+
234+
// Should be identical to CLI
235+
assert_eq!(merged.working_directory, Some("./dir".to_string()));
236+
assert_eq!(merged.mode, RunnerMode::Simulation);
237+
}
238+
239+
#[test]
240+
fn test_merge_option_helper() {
241+
// CLI value wins
242+
let cli_val = Some("cli".to_string());
243+
let config_val = Some("config".to_string());
244+
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
245+
assert_eq!(result, Some("cli".to_string()));
246+
247+
// Config value used when CLI is None
248+
let cli_val: Option<String> = None;
249+
let config_val = Some("config".to_string());
250+
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
251+
assert_eq!(result, Some("config".to_string()));
252+
253+
// Both None
254+
let cli_val: Option<String> = None;
255+
let config_val: Option<String> = None;
256+
let result = ConfigMerger::merge_option(&cli_val, config_val.as_ref());
257+
assert_eq!(result, None);
258+
}
259+
}

0 commit comments

Comments
 (0)