diff --git a/src/error.rs b/src/error.rs index cdf58131..2f4b94f2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,6 +52,9 @@ pub enum IxaError { #[error("invalid log level format: {log_level}")] InvalidLogLevelFormat { log_level: String }, + #[error("invalid runner config section `{section}`: {message}")] + InvalidRunnerConfig { section: String, message: String }, + #[error("cannot make edge to self")] CannotMakeEdgeToSelf, #[error("invalid weight")] diff --git a/src/global_properties.rs b/src/global_properties.rs index ccc144ad..db2378e5 100644 --- a/src/global_properties.rs +++ b/src/global_properties.rs @@ -121,6 +121,21 @@ fn get_global_property_setter_for_config_key(name: &str) -> Option, +) -> Result<(), IxaError> { + for (k, v) in val { + if let Some(setter) = get_global_property_setter_for_config_key(&k) { + setter(context, &k, v)?; + } else { + return Err(IxaError::NoGlobalProperty { name: k }); + } + } + + Ok(()) +} + /// The trait representing a global property. Do not use this /// directly, but instead define global properties with /// [`define_global_property!`](crate::define_global_property!). @@ -266,15 +281,7 @@ impl ContextGlobalPropertiesExt for Context { let reader = BufReader::new(config_file); let val: serde_json::Map = serde_json::from_reader(reader)?; - for (k, v) in val { - if let Some(setter) = get_global_property_setter_for_config_key(&k) { - setter(self, &k, v)?; - } else { - return Err(IxaError::NoGlobalProperty { name: k }); - } - } - - Ok(()) + load_global_properties_from_map(self, val) } } diff --git a/src/lib.rs b/src/lib.rs index 8df6961d..411c514e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,7 +77,7 @@ pub mod report; pub use report::{ConfigReportOptions, ContextReportExt, Report}; pub mod runner; -pub use runner::{run_with_args, run_with_custom_args, BaseArgs}; +pub use runner::{run_with_args, run_with_custom_args, run_with_merged_args, BaseArgs, RunnerArgs}; pub mod log; pub use log::{ diff --git a/src/runner.rs b/src/runner.rs index 864fc45a..ec978e33 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -1,13 +1,17 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; -use clap::{ArgAction, Args, Command, FromArgMatches as _}; +use clap::error::ErrorKind as ClapErrorKind; +use clap::parser::ValueSource; +use clap::{ArgAction, ArgMatches, Args, Command, FromArgMatches as _}; #[cfg(feature = "write_cli_usage")] use clap_markdown::{help_markdown_command_custom, MarkdownOptions}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use crate::context::Context; use crate::error::IxaError; -use crate::global_properties::ContextGlobalPropertiesExt; +use crate::global_properties::load_global_properties_from_map; use crate::log::level_to_string_list; use crate::random::ContextRandomExt; use crate::report::ContextReportExt; @@ -127,11 +131,313 @@ impl Default for BaseArgs { #[derive(Args)] pub struct PlaceholderCustom {} +/// Effective runner arguments after merging defaults, config, and CLI values. +#[derive(Debug)] +pub struct RunnerArgs { + pub base: BaseArgs, + pub custom: A, +} + +#[derive(Default)] +struct LoadedRunnerConfig { + args: Option>, + global_properties: serde_json::Map, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PartialBaseArgs { + random_seed: Option, + output_dir: Option, + file_prefix: Option, + force_overwrite: Option, + log_level: Option, + verbose: Option, + warn: Option, + debug: Option, + trace: Option, + no_stats: Option, +} + fn create_ixa_cli() -> Command { let cli = Command::new("ixa"); BaseArgs::augment_args(cli) } +fn create_ixa_cli_with_custom() -> Command +where + A: Args, +{ + A::augment_args(create_ixa_cli()) +} + +fn read_runner_config(config_path: Option<&Path>) -> Result { + let Some(config_path) = config_path else { + return Ok(LoadedRunnerConfig::default()); + }; + + let config_file = std::fs::File::open(config_path)?; + let reader = std::io::BufReader::new(config_file); + let mut config: serde_json::Map = serde_json::from_reader(reader)?; + let args = match config.remove("args") { + None => None, + Some(serde_json::Value::Object(args)) => Some(args), + Some(_) => { + return Err(IxaError::InvalidRunnerConfig { + section: "args".to_string(), + message: "expected a JSON object".to_string(), + }); + } + }; + + Ok(LoadedRunnerConfig { + args, + global_properties: config, + }) +} + +fn deserialize_runner_config(value: serde_json::Value, section: &str) -> Result +where + T: DeserializeOwned, +{ + serde_json::from_value(value).map_err(|source| IxaError::InvalidRunnerConfig { + section: section.to_string(), + message: source.to_string(), + }) +} + +fn arg_was_set_on_command_line(matches: &ArgMatches, id: &str) -> bool { + matches.value_source(id) == Some(ValueSource::CommandLine) +} + +const LOG_ARG_IDS: [&str; 5] = ["log_level", "verbose", "warn", "debug", "trace"]; + +fn command_line_logging_arg_was_set(matches: &ArgMatches) -> bool { + LOG_ARG_IDS + .iter() + .any(|id| arg_was_set_on_command_line(matches, id)) +} + +fn reset_logging_args(args: &mut BaseArgs) { + args.log_level = None; + args.verbose = 0; + args.warn = false; + args.debug = false; + args.trace = false; +} + +fn apply_partial_base_args(args: &mut BaseArgs, partial: PartialBaseArgs) { + if let Some(random_seed) = partial.random_seed { + args.random_seed = random_seed; + } + if let Some(output_dir) = partial.output_dir { + args.output_dir = Some(output_dir); + } + if let Some(file_prefix) = partial.file_prefix { + args.file_prefix = Some(file_prefix); + } + if let Some(force_overwrite) = partial.force_overwrite { + args.force_overwrite = force_overwrite; + } + if let Some(log_level) = partial.log_level { + args.log_level = Some(log_level); + } + if let Some(verbose) = partial.verbose { + args.verbose = verbose; + } + if let Some(warn) = partial.warn { + args.warn = warn; + } + if let Some(debug) = partial.debug { + args.debug = debug; + } + if let Some(trace) = partial.trace { + args.trace = trace; + } + if let Some(no_stats) = partial.no_stats { + args.no_stats = no_stats; + } +} + +fn apply_command_line_base_args(args: &mut BaseArgs, cli_args: &BaseArgs, matches: &ArgMatches) { + if arg_was_set_on_command_line(matches, "random_seed") { + args.random_seed = cli_args.random_seed; + } + if arg_was_set_on_command_line(matches, "output_dir") { + args.output_dir = cli_args.output_dir.clone(); + } + if arg_was_set_on_command_line(matches, "file_prefix") { + args.file_prefix = cli_args.file_prefix.clone(); + } + if arg_was_set_on_command_line(matches, "force_overwrite") { + args.force_overwrite = cli_args.force_overwrite; + } + if arg_was_set_on_command_line(matches, "log_level") { + args.log_level = cli_args.log_level.clone(); + } + if arg_was_set_on_command_line(matches, "verbose") { + args.verbose = cli_args.verbose; + } + if arg_was_set_on_command_line(matches, "warn") { + args.warn = cli_args.warn; + } + if arg_was_set_on_command_line(matches, "debug") { + args.debug = cli_args.debug; + } + if arg_was_set_on_command_line(matches, "trace") { + args.trace = cli_args.trace; + } + if arg_was_set_on_command_line(matches, "no_stats") { + args.no_stats = cli_args.no_stats; + } +} + +fn merge_base_args( + matches: &ArgMatches, + cli_args: &BaseArgs, + runner_config: Option<&serde_json::Map>, +) -> Result { + let mut args = BaseArgs::default(); + + if let Some(config_object) = runner_config { + let mut config_object = config_object.clone(); + config_object.remove("custom"); + let partial = deserialize_runner_config(serde_json::Value::Object(config_object), "args")?; + apply_partial_base_args(&mut args, partial); + } + + if command_line_logging_arg_was_set(matches) { + reset_logging_args(&mut args); + } + apply_command_line_base_args(&mut args, cli_args, matches); + args.config = cli_args.config.clone(); + + #[cfg(feature = "write_cli_usage")] + { + args.markdown_help = cli_args.markdown_help; + } + + Ok(args) +} + +fn serialize_to_object( + value: &T, + section: &str, +) -> Result, IxaError> +where + T: Serialize, +{ + match serde_json::to_value(value).map_err(|source| IxaError::InvalidRunnerConfig { + section: section.to_string(), + message: source.to_string(), + })? { + serde_json::Value::Object(object) => Ok(object), + _ => Err(IxaError::InvalidRunnerConfig { + section: section.to_string(), + message: "expected custom args to serialize as a JSON object".to_string(), + }), + } +} + +fn merge_custom_args( + matches: &ArgMatches, + cli_args: &A, + runner_config: Option<&serde_json::Map>, +) -> Result +where + A: Args + Serialize + DeserializeOwned + Default, +{ + let cli_args = serialize_to_object(cli_args, "args.custom")?; + let mut args = cli_args.clone(); + let mut config_custom_args = None; + + if let Some(config_object) = runner_config { + if let Some(custom_value) = config_object.get("custom") { + let serde_json::Value::Object(custom_object) = custom_value else { + return Err(IxaError::InvalidRunnerConfig { + section: "args.custom".to_string(), + message: "expected a JSON object".to_string(), + }); + }; + for (key, value) in custom_object { + args.insert(key.clone(), value.clone()); + } + config_custom_args = Some(custom_object); + } + } + + for (key, value) in cli_args { + if arg_was_set_on_command_line(matches, &key) { + args.insert(key, value); + } + } + + validate_required_custom_args::(matches, config_custom_args)?; + deserialize_runner_config(serde_json::Value::Object(args), "args.custom") +} + +fn validate_required_custom_args( + matches: &ArgMatches, + config_custom_args: Option<&serde_json::Map>, +) -> Result<(), IxaError> +where + A: Args, +{ + for id in required_custom_arg_ids::() { + let provided_by_cli = matches.value_source(&id).is_some(); + let provided_by_config = config_custom_args.is_some_and(|custom| custom.contains_key(&id)); + if !provided_by_cli && !provided_by_config { + return Err(IxaError::InvalidRunnerConfig { + section: "args.custom".to_string(), + message: format!( + "missing required custom argument `{id}`; provide it on the command line or in args.custom" + ), + }); + } + } + + Ok(()) +} + +fn required_custom_arg_ids() -> Vec +where + A: Args, +{ + let base_arg_ids: std::collections::HashSet<_> = create_ixa_cli() + .get_arguments() + .map(|arg| arg.get_id().to_string()) + .collect(); + + create_ixa_cli_with_custom::() + .get_arguments() + .filter(|arg| arg.is_required_set()) + .map(|arg| arg.get_id().to_string()) + .filter(|id| !base_arg_ids.contains(id)) + .collect() +} + +fn parse_matches_allowing_config_required_args( + cli: Command, + argv: Vec, +) -> Result { + match cli.clone().try_get_matches_from(argv.clone()) { + Ok(matches) => Ok(matches), + Err(error) if error.kind() == ClapErrorKind::MissingRequiredArgument => cli + .mut_args(|arg| arg.required(false)) + .try_get_matches_from(argv), + Err(error) => Err(error), + } +} + +fn custom_args_from_matches(matches: &ArgMatches) -> Result +where + A: Args + Default, +{ + let mut args = A::default(); + A::update_from_arg_matches(&mut args, matches)?; + Ok(args) +} + /// Runs a simulation with custom cli arguments. /// /// This function allows you to define custom arguments and a setup function @@ -147,13 +453,17 @@ where A: Args, F: Fn(&mut Context, BaseArgs, Option) -> Result<(), IxaError>, { - let mut cli = create_ixa_cli(); - cli = A::augment_args(cli); + let cli = create_ixa_cli_with_custom::(); let matches = cli.get_matches(); let base_args_matches = BaseArgs::from_arg_matches(&matches)?; let custom_matches = A::from_arg_matches(&matches)?; - run_with_args_internal(base_args_matches, Some(custom_matches), setup_fn) + let loaded_config = read_runner_config(base_args_matches.config.as_deref())?; + let effective_base_args = + merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?; + execute_runner(effective_base_args, loaded_config, |context, args| { + setup_fn(context, args, Some(custom_matches)) + }) } /// Runs a simulation with default cli arguments @@ -173,9 +483,53 @@ where let matches = cli.get_matches(); let base_args_matches = BaseArgs::from_arg_matches(&matches)?; - run_with_args_internal(base_args_matches, None, setup_fn) + let loaded_config = read_runner_config(base_args_matches.config.as_deref())?; + let effective_base_args = + merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?; + execute_runner(effective_base_args, loaded_config, |context, args| { + setup_fn(context, args, None) + }) +} + +/// Runs a simulation with merged base and custom arguments from CLI and config. +/// +/// Values in `args` from the JSON config override defaults. Explicit CLI flags +/// override config values. Custom config values are read from `args.custom`. +/// Custom merging supports top-level serde fields whose names match clap arg +/// IDs. Nested or flattened custom layouts are not merged generically. +/// `config` itself is CLI-only and is not read from the config file. +/// +/// # Errors +/// Returns an error if argument parsing, config merging, or the setup function fails. +pub fn run_with_merged_args(setup_fn: F) -> Result> +where + A: Args + Serialize + DeserializeOwned + Default, + F: Fn(&mut Context, RunnerArgs) -> Result<(), IxaError>, +{ + let cli = create_ixa_cli_with_custom::(); + let argv: Vec<_> = std::env::args_os().collect(); + let matches = parse_matches_allowing_config_required_args(cli, argv)?; + + let base_args_matches = BaseArgs::from_arg_matches(&matches)?; + let custom_matches = custom_args_from_matches::(&matches)?; + let loaded_config = read_runner_config(base_args_matches.config.as_deref())?; + let effective_base_args = + merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?; + let effective_custom_args = + merge_custom_args(&matches, &custom_matches, loaded_config.args.as_ref())?; + + execute_runner(effective_base_args, loaded_config, |context, base| { + setup_fn( + context, + RunnerArgs { + base, + custom: effective_custom_args, + }, + ) + }) } +#[cfg(test)] fn run_with_args_internal( args: BaseArgs, custom_args: Option, @@ -183,6 +537,20 @@ fn run_with_args_internal( ) -> Result> where F: Fn(&mut Context, BaseArgs, Option) -> Result<(), IxaError>, +{ + let loaded_config = read_runner_config(args.config.as_deref())?; + execute_runner(args, loaded_config, |context, args| { + setup_fn(context, args, custom_args) + }) +} + +fn execute_runner( + args: BaseArgs, + loaded_config: LoadedRunnerConfig, + setup_fn: F, +) -> Result> +where + F: FnOnce(&mut Context, BaseArgs) -> Result<(), IxaError>, { #[cfg(feature = "write_cli_usage")] // Output help to a markdown file @@ -216,7 +584,7 @@ where if args.config.is_some() { let config_path = args.config.clone().unwrap(); println!("Loading global properties from: {config_path:?}"); - context.load_global_properties(&config_path)?; + load_global_properties_from_map(&mut context, loaded_config.global_properties)?; } // Configure report options @@ -308,7 +676,7 @@ where } // Run the provided Fn - setup_fn(&mut context, args, custom_args)?; + setup_fn(&mut context, args)?; // Execute the context context.execute(); @@ -317,9 +685,15 @@ where #[cfg(test)] mod tests { + use std::ffi::OsString; + use std::fs; + use serde::{Deserialize, Serialize}; + use serde_json::json; + use tempfile::tempdir; use super::*; + use crate::global_properties::ContextGlobalPropertiesExt; use crate::{define_global_property, define_rng}; fn fixture_path(name: &str) -> PathBuf { @@ -328,21 +702,253 @@ mod tests { .join(name) } - #[derive(Args, Debug)] + #[derive(Args, Debug, Default, Serialize, Deserialize)] struct CustomArgs { #[arg(short, long, default_value = "0")] a: u32, } + #[derive(Args, Debug, Default, Serialize, Deserialize)] + struct CustomArgsWithClapDefault { + #[arg(long, default_value_t = 10)] + count: u32, + } + + #[derive(Args, Debug, Default, Serialize, Deserialize)] + struct RequiredCustomArgs { + #[arg(long)] + path: PathBuf, + } + + fn parse_base_args_from(argv: [&str; N]) -> (ArgMatches, BaseArgs) { + let matches = create_ixa_cli().try_get_matches_from(argv).unwrap(); + let base_args = BaseArgs::from_arg_matches(&matches).unwrap(); + (matches, base_args) + } + + fn parse_custom_args_from(argv: [&str; N]) -> (ArgMatches, A) + where + A: Args + Default, + { + let matches = parse_matches_allowing_config_required_args( + create_ixa_cli_with_custom::(), + argv.into_iter().map(OsString::from).collect(), + ) + .unwrap(); + let custom_args = custom_args_from_matches::(&matches).unwrap(); + (matches, custom_args) + } + + fn json_object(value: serde_json::Value) -> serde_json::Map { + match value { + serde_json::Value::Object(object) => object, + _ => panic!("test value must be a JSON object"), + } + } + + #[test] + fn test_merge_base_args_uses_defaults_without_config() { + let (matches, cli_args) = parse_base_args_from(["ixa"]); + let args = merge_base_args(&matches, &cli_args, None).unwrap(); + + assert_eq!(args.random_seed, 0); + assert_eq!(args.output_dir, None); + assert_eq!(args.file_prefix, None); + assert!(!args.force_overwrite); + } + + #[test] + fn test_merge_base_args_uses_config_values() { + let (matches, cli_args) = parse_base_args_from(["ixa"]); + let config = json_object(json!({ + "random_seed": 42, + "output_dir": "data", + "file_prefix": "cfg_", + "force_overwrite": true + })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.random_seed, 42); + assert_eq!(args.output_dir, Some(PathBuf::from("data"))); + assert_eq!(args.file_prefix, Some("cfg_".to_string())); + assert!(args.force_overwrite); + } + + #[test] + fn test_merge_base_args_cli_overrides_config_values() { + let (matches, cli_args) = parse_base_args_from([ + "ixa", + "--random-seed", + "7", + "--output", + "cli-data", + "--prefix", + "cli_", + "--force-overwrite", + ]); + let config = json_object(json!({ + "random_seed": 42, + "output_dir": "data", + "file_prefix": "cfg_", + "force_overwrite": false + })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.random_seed, 7); + assert_eq!(args.output_dir, Some(PathBuf::from("cli-data"))); + assert_eq!(args.file_prefix, Some("cli_".to_string())); + assert!(args.force_overwrite); + } + + #[test] + fn test_merge_base_args_clap_default_does_not_override_config() { + let (matches, cli_args) = parse_base_args_from(["ixa"]); + let config = json_object(json!({ "random_seed": 42 })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.random_seed, 42); + } + + #[test] + fn test_merge_base_args_cli_warn_overrides_config_log_level() { + let (matches, cli_args) = parse_base_args_from(["ixa", "--warn"]); + let config = json_object(json!({ "log_level": "trace" })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.log_level, None); + assert_eq!(args.verbose, 0); + assert!(args.warn); + assert!(!args.debug); + assert!(!args.trace); + } + + #[test] + fn test_merge_base_args_cli_log_level_overrides_config_verbose() { + let (matches, cli_args) = parse_base_args_from(["ixa", "--log-level", "error"]); + let config = json_object(json!({ "verbose": 3 })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.log_level, Some("error".to_string())); + assert_eq!(args.verbose, 0); + assert!(!args.warn); + assert!(!args.debug); + assert!(!args.trace); + } + + #[test] + fn test_merge_base_args_rejects_malformed_args_section() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.json"); + fs::write(&config_path, r#"{ "args": [] }"#).unwrap(); + + let err = read_runner_config(Some(&config_path)).err().unwrap(); + + assert!(matches!( + err, + IxaError::InvalidRunnerConfig { section, .. } if section == "args" + )); + } + + #[test] + fn test_merge_base_args_rejects_unknown_fields() { + let (matches, cli_args) = parse_base_args_from(["ixa"]); + let config = json_object(json!({ "random-seed": 42 })); + let err = merge_base_args(&matches, &cli_args, Some(&config)).unwrap_err(); + + assert!(matches!( + err, + IxaError::InvalidRunnerConfig { section, .. } if section == "args" + )); + } + + #[test] + fn test_merge_base_args_allows_custom_section() { + let (matches, cli_args) = parse_base_args_from(["ixa"]); + let config = json_object(json!({ "custom": { "a": 7 } })); + let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.random_seed, 0); + } + + #[test] + fn test_merge_custom_args_uses_config_values() { + let mut cli = create_ixa_cli(); + cli = CustomArgs::augment_args(cli); + let matches = cli.try_get_matches_from(["ixa"]).unwrap(); + let cli_args = CustomArgs::from_arg_matches(&matches).unwrap(); + let config = json_object(json!({ "custom": { "a": 7 } })); + let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.a, 7); + } + + #[test] + fn test_merge_custom_args_cli_overrides_config_values() { + let mut cli = create_ixa_cli(); + cli = CustomArgs::augment_args(cli); + let matches = cli.try_get_matches_from(["ixa", "--a", "9"]).unwrap(); + let cli_args = CustomArgs::from_arg_matches(&matches).unwrap(); + let config = json_object(json!({ "custom": { "a": 7 } })); + let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.a, 9); + } + + #[test] + fn test_merge_custom_args_preserves_clap_defaults() { + let (matches, cli_args) = parse_custom_args_from::(["ixa"]); + let args = merge_custom_args(&matches, &cli_args, None).unwrap(); + + assert_eq!(args.count, 10); + } + + #[test] + fn test_merge_custom_args_allows_required_args_from_config() { + let (matches, cli_args) = parse_custom_args_from::(["ixa"]); + let config = json_object(json!({ "custom": { "path": "from-config.json" } })); + let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap(); + + assert_eq!(args.path, PathBuf::from("from-config.json")); + } + + #[test] + fn test_merge_custom_args_rejects_missing_required_args() { + let (matches, cli_args) = parse_custom_args_from::(["ixa"]); + let err = merge_custom_args(&matches, &cli_args, None).unwrap_err(); + + assert!(matches!( + err, + IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom" + )); + } + + #[test] + fn test_merge_custom_args_rejects_malformed_custom_section() { + let mut cli = create_ixa_cli(); + cli = CustomArgs::augment_args(cli); + let matches = cli.try_get_matches_from(["ixa"]).unwrap(); + let cli_args = CustomArgs::from_arg_matches(&matches).unwrap(); + let config = json_object(json!({ "custom": 7 })); + let err = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap_err(); + + assert!(matches!( + err, + IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom" + )); + } + #[test] fn test_run_with_custom_args() { - let result = run_with_custom_args(|_, _, _: Option| Ok(())); + let result = + run_with_args_internal(BaseArgs::new(), Some(CustomArgs::default()), |_, _, _| { + Ok(()) + }); assert!(result.is_ok()); } #[test] fn test_run_with_args() { - let result = run_with_args(|_, _, _| Ok(())); + let result = run_with_args_internal(BaseArgs::new(), None, |_, _, _: Option<()>| Ok(())); assert!(result.is_ok()); } @@ -387,6 +993,36 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_run_with_config_path_ignores_args_for_global_properties() { + let temp_dir = tempdir().unwrap(); + let config_path = temp_dir.path().join("config.json"); + fs::write( + &config_path, + r#"{ + "args": { + "random_seed": 42 + }, + "ixa.RunnerProperty": { + "field_int": 7 + } + } + "#, + ) + .unwrap(); + + let test_args = BaseArgs { + config: Some(config_path), + ..Default::default() + }; + let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| { + let property = ctx.get_global_property_value(RunnerProperty).unwrap(); + assert_eq!(property.field_int, 7); + Ok(()) + }); + assert!(result.is_ok()); + } + #[test] fn test_run_with_report_options() { let test_args = BaseArgs {