1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use clap::error::ErrorKind as ClapErrorKind;
5use clap::parser::ValueSource;
6use clap::{ArgAction, ArgMatches, Args, Command, FromArgMatches as _};
7#[cfg(feature = "write_cli_usage")]
8use clap_markdown::{help_markdown_command_custom, MarkdownOptions};
9use serde::de::DeserializeOwned;
10use serde::{Deserialize, Serialize};
11
12use crate::context::Context;
13use crate::error::IxaError;
14use crate::global_properties::load_global_properties_from_map;
15use crate::log::level_to_string_list;
16use crate::random::ContextRandomExt;
17use crate::report::ContextReportExt;
18use crate::{info, set_log_level, set_module_filters, HashSet, LevelFilter};
19
20fn parse_log_levels(s: &str) -> Result<Vec<(String, LevelFilter)>, IxaError> {
22 s.split(',')
23 .map(|pair| {
24 let mut iter = pair.split('=');
25 let key = iter.next().ok_or_else(|| IxaError::InvalidLogLevelKey {
26 pair: pair.to_string(),
27 })?;
28 let value = iter.next().ok_or_else(|| IxaError::InvalidLogLevelValue {
29 pair: pair.to_string(),
30 })?;
31 let level = LevelFilter::from_str(value).map_err(|_| IxaError::InvalidLogLevel {
32 level: value.to_string(),
33 })?;
34 Ok((key.to_string(), level))
35 })
36 .collect()
37}
38
39#[derive(Args, Debug, Clone, Serialize, Deserialize)]
41pub struct BaseArgs {
42 #[cfg(feature = "write_cli_usage")]
43 #[arg(long, hide = true)]
47 markdown_help: bool,
48
49 #[arg(short, long, default_value = "0")]
51 pub random_seed: u64,
52
53 #[arg(short, long)]
55 pub config: Option<PathBuf>,
56
57 #[arg(short, long = "output")]
59 pub output_dir: Option<PathBuf>,
60
61 #[arg(long = "prefix")]
63 pub file_prefix: Option<String>,
64
65 #[arg(short, long)]
67 pub force_overwrite: bool,
68
69 #[arg(short, long)]
71 pub log_level: Option<String>,
72
73 #[arg(
74 short,
75 long,
76 action = ArgAction::Count,
77 long_help = r#"Increase logging verbosity (-v, -vv, -vvv, etc.)
78
79| Level | ERROR | WARN | INFO | DEBUG | TRACE |
80|---------|-------|------|------|-------|-------|
81| Default | ✓ | | | | |
82| -v | ✓ | ✓ | ✓ | | |
83| -vv | ✓ | ✓ | ✓ | ✓ | |
84| -vvv | ✓ | ✓ | ✓ | ✓ | ✓ |
85"#)]
86 pub verbose: u8,
87
88 #[arg(long)]
90 pub warn: bool,
91
92 #[arg(long)]
94 pub debug: bool,
95
96 #[arg(long)]
98 pub trace: bool,
99
100 #[arg(long)]
102 pub no_stats: bool,
103}
104
105impl BaseArgs {
106 fn new() -> Self {
107 BaseArgs {
108 #[cfg(feature = "write_cli_usage")]
109 markdown_help: false,
110 random_seed: 0,
111 config: None,
112 output_dir: None,
113 file_prefix: None,
114 force_overwrite: false,
115 log_level: None,
116 verbose: 0,
117 warn: false,
118 debug: false,
119 trace: false,
120 no_stats: false,
121 }
122 }
123}
124
125impl Default for BaseArgs {
126 fn default() -> Self {
127 BaseArgs::new()
128 }
129}
130
131#[derive(Args)]
132pub struct PlaceholderCustom {}
133
134#[derive(Debug)]
136pub struct RunnerArgs<A> {
137 pub base: BaseArgs,
138 pub custom: A,
139}
140
141#[derive(Default)]
142struct LoadedRunnerConfig {
143 args: Option<serde_json::Map<String, serde_json::Value>>,
144 global_properties: serde_json::Map<String, serde_json::Value>,
145}
146
147fn create_ixa_cli() -> Command {
148 let cli = Command::new("ixa");
149 BaseArgs::augment_args(cli)
150}
151
152fn create_ixa_cli_with_custom<A>() -> Command
153where
154 A: Args,
155{
156 A::augment_args(create_ixa_cli())
157}
158
159fn read_runner_config(config_path: Option<&Path>) -> Result<LoadedRunnerConfig, IxaError> {
160 let Some(config_path) = config_path else {
161 return Ok(LoadedRunnerConfig::default());
162 };
163
164 let config_file = std::fs::File::open(config_path)?;
165 let reader = std::io::BufReader::new(config_file);
166 let mut config: serde_json::Map<String, serde_json::Value> = serde_json::from_reader(reader)?;
167 let args = match config.remove("args") {
168 None => None,
169 Some(serde_json::Value::Object(args)) => Some(args),
170 Some(_) => {
171 return Err(IxaError::InvalidRunnerConfig {
172 section: "args".to_string(),
173 message: "expected a JSON object".to_string(),
174 });
175 }
176 };
177
178 Ok(LoadedRunnerConfig {
179 args,
180 global_properties: config,
181 })
182}
183
184fn reject_unmerged_custom_args(loaded_config: &LoadedRunnerConfig) -> Result<(), IxaError> {
187 let has_custom = loaded_config
188 .args
189 .as_ref()
190 .is_some_and(|args| args.contains_key("custom"));
191 if has_custom {
192 return Err(IxaError::InvalidRunnerConfig {
193 section: "args.custom".to_string(),
194 message: "custom config args are only merged by `run_with_merged_args`; remove `args.custom` from the config or switch entry points".to_string(),
195 });
196 }
197 Ok(())
198}
199
200fn deserialize_runner_config<T>(value: serde_json::Value, section: &str) -> Result<T, IxaError>
201where
202 T: DeserializeOwned,
203{
204 serde_json::from_value(value).map_err(|source| IxaError::InvalidRunnerConfig {
205 section: section.to_string(),
206 message: source.to_string(),
207 })
208}
209
210fn arg_was_set_on_command_line(matches: &ArgMatches, id: &str) -> bool {
211 matches.value_source(id) == Some(ValueSource::CommandLine)
212}
213
214const LOG_ARG_IDS: [&str; 5] = ["log_level", "verbose", "warn", "debug", "trace"];
215
216const CLI_ONLY_ARG_IDS: [&str; 2] = ["config", "markdown_help"];
219
220const PATH_ARG_IDS: [&str; 2] = ["config", "output_dir"];
222
223fn command_line_logging_arg_was_set(matches: &ArgMatches) -> bool {
224 LOG_ARG_IDS
225 .iter()
226 .any(|id| arg_was_set_on_command_line(matches, id))
227}
228
229fn arg_ids(command: &Command) -> HashSet<String> {
230 command
231 .get_arguments()
232 .map(|arg| arg.get_id().to_string())
233 .collect()
234}
235
236fn custom_only_arg_ids<A>() -> HashSet<String>
237where
238 A: Args,
239{
240 let base_arg_ids = arg_ids(&create_ixa_cli());
241 let mut ids = arg_ids(&create_ixa_cli_with_custom::<A>());
242 ids.retain(|id| !base_arg_ids.contains(id));
243 ids
244}
245
246fn merge_base_args(
247 matches: &ArgMatches,
248 cli_args: &BaseArgs,
249 runner_config: Option<&serde_json::Map<String, serde_json::Value>>,
250) -> Result<BaseArgs, IxaError> {
251 let defaults = serialize_to_object(&BaseArgs::default(), "args")?;
252 let mut merged = defaults.clone();
253
254 if let Some(config_object) = runner_config {
255 let base_arg_ids = arg_ids(&create_ixa_cli());
256 for (key, value) in config_object {
257 if key == "custom" {
258 continue;
259 }
260 if !base_arg_ids.contains(key) || CLI_ONLY_ARG_IDS.contains(&key.as_str()) {
261 let mut allowed: Vec<&str> = base_arg_ids
262 .iter()
263 .map(String::as_str)
264 .filter(|id| !CLI_ONLY_ARG_IDS.contains(id))
265 .collect();
266 allowed.sort_unstable();
267 return Err(IxaError::InvalidRunnerConfig {
268 section: "args".to_string(),
269 message: format!(
270 "unknown field `{key}`, expected one of: {}",
271 allowed.join(", ")
272 ),
273 });
274 }
275 merged.insert(key.clone(), value.clone());
276 }
277 }
278
279 if command_line_logging_arg_was_set(matches) {
282 for id in LOG_ARG_IDS {
283 merged.insert(id.to_string(), defaults[id].clone());
284 }
285 }
286
287 let mut cli_utf8 = cli_args.clone();
290 cli_utf8.config = None;
291 cli_utf8.output_dir = None;
292 for (key, value) in serialize_to_object(&cli_utf8, "args")? {
293 if PATH_ARG_IDS.contains(&key.as_str()) {
294 continue;
295 }
296 if arg_was_set_on_command_line(matches, &key) {
297 merged.insert(key, value);
298 }
299 }
300
301 let mut args: BaseArgs = deserialize_runner_config(serde_json::Value::Object(merged), "args")?;
302 args.config = cli_args.config.clone();
303 if arg_was_set_on_command_line(matches, "output_dir") {
304 args.output_dir = cli_args.output_dir.clone();
305 }
306 Ok(args)
307}
308
309fn serialize_to_object<T>(
310 value: &T,
311 section: &str,
312) -> Result<serde_json::Map<String, serde_json::Value>, IxaError>
313where
314 T: Serialize,
315{
316 match serde_json::to_value(value).map_err(|source| IxaError::InvalidRunnerConfig {
317 section: section.to_string(),
318 message: source.to_string(),
319 })? {
320 serde_json::Value::Object(object) => Ok(object),
321 _ => Err(IxaError::InvalidRunnerConfig {
322 section: section.to_string(),
323 message: format!("expected `{section}` to serialize as a JSON object"),
324 }),
325 }
326}
327
328fn merge_custom_args<A>(
329 matches: &ArgMatches,
330 cli_args: &A,
331 runner_config: Option<&serde_json::Map<String, serde_json::Value>>,
332) -> Result<A, IxaError>
333where
334 A: Args + Serialize + DeserializeOwned + Default,
335{
336 let cli_values = serialize_to_object(cli_args, "args.custom")?;
337 let custom_arg_ids = custom_only_arg_ids::<A>();
338 let mut args = cli_values.clone();
339 let mut config_custom_args = None;
340
341 if let Some(config_object) = runner_config {
342 if let Some(custom_value) = config_object.get("custom") {
343 let serde_json::Value::Object(custom_object) = custom_value else {
344 return Err(IxaError::InvalidRunnerConfig {
345 section: "args.custom".to_string(),
346 message: "expected a JSON object".to_string(),
347 });
348 };
349 for (key, value) in custom_object {
350 if !custom_arg_ids.contains(key) && !cli_values.contains_key(key) {
351 return Err(IxaError::InvalidRunnerConfig {
352 section: "args.custom".to_string(),
353 message: format!("unknown field `{key}`"),
354 });
355 }
356 args.insert(key.clone(), value.clone());
357 }
358 config_custom_args = Some(custom_object);
359 }
360 }
361
362 for (key, value) in cli_values {
365 if custom_arg_ids.contains(&key) && arg_was_set_on_command_line(matches, &key) {
366 args.insert(key, value);
367 }
368 }
369
370 validate_required_custom_args::<A>(matches, config_custom_args)?;
371 deserialize_runner_config(serde_json::Value::Object(args), "args.custom")
372}
373
374fn validate_required_custom_args<A>(
375 matches: &ArgMatches,
376 config_custom_args: Option<&serde_json::Map<String, serde_json::Value>>,
377) -> Result<(), IxaError>
378where
379 A: Args,
380{
381 for id in required_custom_arg_ids::<A>() {
382 let provided_by_cli = matches.value_source(&id).is_some();
383 let provided_by_config = config_custom_args.is_some_and(|custom| custom.contains_key(&id));
384 if !provided_by_cli && !provided_by_config {
385 return Err(IxaError::InvalidRunnerConfig {
386 section: "args.custom".to_string(),
387 message: format!(
388 "missing required custom argument `{id}`; provide it on the command line or in args.custom"
389 ),
390 });
391 }
392 }
393
394 Ok(())
395}
396
397fn required_custom_arg_ids<A>() -> Vec<String>
398where
399 A: Args,
400{
401 let base_arg_ids = arg_ids(&create_ixa_cli());
402
403 create_ixa_cli_with_custom::<A>()
404 .get_arguments()
405 .filter(|arg| arg.is_required_set())
406 .map(|arg| arg.get_id().to_string())
407 .filter(|id| !base_arg_ids.contains(id))
408 .collect()
409}
410
411fn parse_matches_allowing_config_required_args(
412 cli: Command,
413 argv: Vec<std::ffi::OsString>,
414) -> Result<ArgMatches, clap::Error> {
415 match cli.clone().try_get_matches_from(argv.clone()) {
416 Ok(matches) => Ok(matches),
417 Err(error) if error.kind() == ClapErrorKind::MissingRequiredArgument => cli
418 .mut_args(|arg| arg.required(false))
419 .try_get_matches_from(argv),
420 Err(error) => Err(error),
421 }
422}
423
424fn custom_args_from_matches<A>(matches: &ArgMatches) -> Result<A, clap::Error>
425where
426 A: Args + Default,
427{
428 let mut args = A::default();
429 A::update_from_arg_matches(&mut args, matches)?;
430 Ok(args)
431}
432
433pub fn run_with_custom_args<A, F>(setup_fn: F) -> Result<Context, Box<dyn std::error::Error>>
445where
446 A: Args,
447 F: Fn(&mut Context, BaseArgs, Option<A>) -> Result<(), IxaError>,
448{
449 let cli = create_ixa_cli_with_custom::<A>();
450 let matches = cli.get_matches();
451
452 let base_args_matches = BaseArgs::from_arg_matches(&matches)?;
453 let custom_matches = A::from_arg_matches(&matches)?;
454 let loaded_config = read_runner_config(base_args_matches.config.as_deref())?;
455 reject_unmerged_custom_args(&loaded_config)?;
456 let effective_base_args =
457 merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?;
458 execute_runner(effective_base_args, loaded_config, |context, args| {
459 setup_fn(context, args, Some(custom_matches))
460 })
461}
462
463pub fn run_with_args<F>(setup_fn: F) -> Result<Context, Box<dyn std::error::Error>>
474where
475 F: Fn(&mut Context, BaseArgs, Option<PlaceholderCustom>) -> Result<(), IxaError>,
476{
477 let cli = create_ixa_cli();
478 let matches = cli.get_matches();
479
480 let base_args_matches = BaseArgs::from_arg_matches(&matches)?;
481 let loaded_config = read_runner_config(base_args_matches.config.as_deref())?;
482 reject_unmerged_custom_args(&loaded_config)?;
483 let effective_base_args =
484 merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?;
485 execute_runner(effective_base_args, loaded_config, |context, args| {
486 setup_fn(context, args, None)
487 })
488}
489
490pub fn run_with_merged_args<A, F>(setup_fn: F) -> Result<Context, Box<dyn std::error::Error>>
509where
510 A: Args + Serialize + DeserializeOwned + Default,
511 F: Fn(&mut Context, RunnerArgs<A>) -> Result<(), IxaError>,
512{
513 let cli = create_ixa_cli_with_custom::<A>();
514 let argv: Vec<_> = std::env::args_os().collect();
515 let matches =
517 parse_matches_allowing_config_required_args(cli, argv).unwrap_or_else(|error| error.exit());
518
519 let base_args_matches = BaseArgs::from_arg_matches(&matches)?;
520 let custom_matches = custom_args_from_matches::<A>(&matches)?;
521 let loaded_config = read_runner_config(base_args_matches.config.as_deref())?;
522 let effective_base_args =
523 merge_base_args(&matches, &base_args_matches, loaded_config.args.as_ref())?;
524 let effective_custom_args =
525 merge_custom_args(&matches, &custom_matches, loaded_config.args.as_ref())?;
526
527 execute_runner(effective_base_args, loaded_config, |context, base| {
528 setup_fn(
529 context,
530 RunnerArgs {
531 base,
532 custom: effective_custom_args,
533 },
534 )
535 })
536}
537
538#[cfg(test)]
539fn run_with_args_internal<A, F>(
540 args: BaseArgs,
541 custom_args: Option<A>,
542 setup_fn: F,
543) -> Result<Context, Box<dyn std::error::Error>>
544where
545 F: Fn(&mut Context, BaseArgs, Option<A>) -> Result<(), IxaError>,
546{
547 let loaded_config = read_runner_config(args.config.as_deref())?;
548 reject_unmerged_custom_args(&loaded_config)?;
549 execute_runner(args, loaded_config, |context, args| {
550 setup_fn(context, args, custom_args)
551 })
552}
553
554fn execute_runner<F>(
555 args: BaseArgs,
556 loaded_config: LoadedRunnerConfig,
557 setup_fn: F,
558) -> Result<Context, Box<dyn std::error::Error>>
559where
560 F: FnOnce(&mut Context, BaseArgs) -> Result<(), IxaError>,
561{
562 #[cfg(feature = "write_cli_usage")]
563 if args.markdown_help {
565 let cli = create_ixa_cli();
566 let md_options = MarkdownOptions::new()
567 .show_footer(false)
568 .show_aliases(true)
569 .show_table_of_contents(false)
570 .title("Command Line Usage".to_string());
571 let markdown = help_markdown_command_custom(&cli, &md_options);
572 let path =
573 PathBuf::from(option_env!("CARGO_WORKSPACE_DIR").unwrap_or(env!("CARGO_MANIFEST_DIR")))
574 .join("docs")
575 .join("book")
576 .join("src")
577 .join("cli-usage.md");
578 std::fs::write(&path, markdown).unwrap_or_else(|e| {
579 panic!(
580 "Failed to write CLI help Markdown to file {}: {}",
581 path.display(),
582 e
583 );
584 });
585 }
586
587 let mut context = Context::new();
589
590 if args.config.is_some() {
592 let config_path = args.config.clone().unwrap();
593 println!("Loading global properties from: {config_path:?}");
594 load_global_properties_from_map(&mut context, loaded_config.global_properties)?;
595 }
596
597 let report_config = context.report_options();
599 if args.output_dir.is_some() {
600 report_config.directory(args.output_dir.clone().unwrap());
601 }
602 if args.file_prefix.is_some() {
603 report_config.file_prefix(args.file_prefix.clone().unwrap());
604 }
605 if args.force_overwrite {
606 report_config.overwrite(true);
607 }
608
609 let mut current_log_level = crate::log::DEFAULT_LOG_LEVEL;
613
614 if let Some(log_level) = args.log_level.as_ref() {
616 if let Ok(level) = LevelFilter::from_str(log_level) {
617 current_log_level = level;
618 } else {
619 match parse_log_levels(log_level) {
620 Ok(log_levels) => {
621 let log_levels_slice: Vec<(&String, LevelFilter)> =
622 log_levels.iter().map(|(k, v)| (k, *v)).collect();
623 set_module_filters(log_levels_slice.as_slice());
624 for (key, value) in log_levels {
625 println!("Logging enabled for {key} at level {value}");
626 }
628 }
629 Err(e) => return Err(Box::new(e)),
630 }
631 }
632 }
633
634 if args.verbose > 0 {
636 let new_level = match args.verbose {
637 1 => LevelFilter::Info,
638 2 => LevelFilter::Debug,
639 _ => LevelFilter::Trace,
640 };
641 current_log_level = current_log_level.max(new_level);
642 }
643
644 if args.warn {
646 current_log_level = current_log_level.max(LevelFilter::Warn);
647 }
648 if args.debug {
649 current_log_level = current_log_level.max(LevelFilter::Debug);
650 }
651 if args.trace {
652 current_log_level = LevelFilter::Trace;
653 }
654
655 let binary_name = std::env::args().next();
657 let binary_name = binary_name
658 .as_deref()
659 .map(Path::new)
660 .and_then(Path::file_name)
661 .and_then(|s| s.to_str())
662 .unwrap_or("[model]");
663 println!(
664 "Current log levels enabled: {}",
665 level_to_string_list(current_log_level)
666 );
667 println!("Run {binary_name} --help -v to see more options");
668
669 if current_log_level != crate::log::DEFAULT_LOG_LEVEL {
671 set_log_level(current_log_level);
672 }
673
674 context.init_random(args.random_seed);
675
676 if args.no_stats {
677 context.print_execution_statistics = false;
678 } else {
679 if cfg!(target_family = "wasm") {
680 info!("the print-stats option is enabled; some statistics are not supported for the wasm target family");
681 }
682 context.print_execution_statistics = true;
683 }
684
685 setup_fn(&mut context, args)?;
687
688 context.execute();
690 Ok(context)
691}
692
693#[cfg(test)]
694mod tests {
695 use std::ffi::OsString;
696 use std::fs;
697
698 use serde::{Deserialize, Serialize};
699 use serde_json::json;
700 use tempfile::tempdir;
701
702 use super::*;
703 use crate::global_properties::ContextGlobalPropertiesExt;
704 use crate::{define_global_property, define_rng};
705
706 fn fixture_path(name: &str) -> PathBuf {
707 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
708 .join("integration-tests/fixtures/global-properties")
709 .join(name)
710 }
711
712 #[derive(Args, Debug, Default, Serialize, Deserialize)]
713 struct CustomArgs {
714 #[arg(short, long, default_value = "0")]
715 a: u32,
716 }
717
718 #[derive(Args, Debug, Default, Serialize, Deserialize)]
719 struct CustomArgsWithClapDefault {
720 #[arg(long, default_value_t = 10)]
721 count: u32,
722 }
723
724 #[derive(Args, Debug, Default, Serialize, Deserialize)]
725 struct RequiredCustomArgs {
726 #[arg(long)]
727 path: PathBuf,
728 }
729
730 fn parse_base_args_from<const N: usize>(argv: [&str; N]) -> (ArgMatches, BaseArgs) {
731 let matches = create_ixa_cli().try_get_matches_from(argv).unwrap();
732 let base_args = BaseArgs::from_arg_matches(&matches).unwrap();
733 (matches, base_args)
734 }
735
736 fn parse_custom_args_from<A, const N: usize>(argv: [&str; N]) -> (ArgMatches, A)
737 where
738 A: Args + Default,
739 {
740 let matches = parse_matches_allowing_config_required_args(
741 create_ixa_cli_with_custom::<A>(),
742 argv.into_iter().map(OsString::from).collect(),
743 )
744 .unwrap();
745 let custom_args = custom_args_from_matches::<A>(&matches).unwrap();
746 (matches, custom_args)
747 }
748
749 fn json_object(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
750 match value {
751 serde_json::Value::Object(object) => object,
752 _ => panic!("test value must be a JSON object"),
753 }
754 }
755
756 #[test]
757 fn test_merge_base_args_uses_defaults_without_config() {
758 let (matches, cli_args) = parse_base_args_from(["ixa"]);
759 let args = merge_base_args(&matches, &cli_args, None).unwrap();
760
761 assert_eq!(args.random_seed, 0);
762 assert_eq!(args.output_dir, None);
763 assert_eq!(args.file_prefix, None);
764 assert!(!args.force_overwrite);
765 }
766
767 #[test]
768 fn test_merge_base_args_uses_config_values() {
769 let (matches, cli_args) = parse_base_args_from(["ixa"]);
770 let config = json_object(json!({
771 "random_seed": 42,
772 "output_dir": "data",
773 "file_prefix": "cfg_",
774 "force_overwrite": true
775 }));
776 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
777
778 assert_eq!(args.random_seed, 42);
779 assert_eq!(args.output_dir, Some(PathBuf::from("data")));
780 assert_eq!(args.file_prefix, Some("cfg_".to_string()));
781 assert!(args.force_overwrite);
782 }
783
784 #[test]
785 fn test_merge_base_args_cli_overrides_config_values() {
786 let (matches, cli_args) = parse_base_args_from([
787 "ixa",
788 "--random-seed",
789 "7",
790 "--output",
791 "cli-data",
792 "--prefix",
793 "cli_",
794 "--force-overwrite",
795 ]);
796 let config = json_object(json!({
797 "random_seed": 42,
798 "output_dir": "data",
799 "file_prefix": "cfg_",
800 "force_overwrite": false
801 }));
802 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
803
804 assert_eq!(args.random_seed, 7);
805 assert_eq!(args.output_dir, Some(PathBuf::from("cli-data")));
806 assert_eq!(args.file_prefix, Some("cli_".to_string()));
807 assert!(args.force_overwrite);
808 }
809
810 #[test]
811 fn test_merge_base_args_clap_default_does_not_override_config() {
812 let (matches, cli_args) = parse_base_args_from(["ixa"]);
813 let config = json_object(json!({ "random_seed": 42 }));
814 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
815
816 assert_eq!(args.random_seed, 42);
817 }
818
819 #[test]
820 fn test_merge_base_args_cli_warn_overrides_config_log_level() {
821 let (matches, cli_args) = parse_base_args_from(["ixa", "--warn"]);
822 let config = json_object(json!({ "log_level": "trace" }));
823 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
824
825 assert_eq!(args.log_level, None);
826 assert_eq!(args.verbose, 0);
827 assert!(args.warn);
828 assert!(!args.debug);
829 assert!(!args.trace);
830 }
831
832 #[test]
833 fn test_merge_base_args_cli_log_level_overrides_config_verbose() {
834 let (matches, cli_args) = parse_base_args_from(["ixa", "--log-level", "error"]);
835 let config = json_object(json!({ "verbose": 3 }));
836 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
837
838 assert_eq!(args.log_level, Some("error".to_string()));
839 assert_eq!(args.verbose, 0);
840 assert!(!args.warn);
841 assert!(!args.debug);
842 assert!(!args.trace);
843 }
844
845 #[test]
846 fn test_merge_base_args_rejects_malformed_args_section() {
847 let temp_dir = tempdir().unwrap();
848 let config_path = temp_dir.path().join("config.json");
849 fs::write(&config_path, r#"{ "args": [] }"#).unwrap();
850
851 let err = read_runner_config(Some(&config_path)).err().unwrap();
852
853 assert!(matches!(
854 err,
855 IxaError::InvalidRunnerConfig { section, .. } if section == "args"
856 ));
857 }
858
859 #[test]
860 fn test_merge_base_args_rejects_unknown_fields() {
861 let (matches, cli_args) = parse_base_args_from(["ixa"]);
862 let config = json_object(json!({ "random-seed": 42 }));
863 let err = merge_base_args(&matches, &cli_args, Some(&config)).unwrap_err();
864
865 assert!(matches!(
866 err,
867 IxaError::InvalidRunnerConfig { section, .. } if section == "args"
868 ));
869 }
870
871 #[test]
872 fn test_merge_base_args_allows_custom_section() {
873 let (matches, cli_args) = parse_base_args_from(["ixa"]);
874 let config = json_object(json!({ "custom": { "a": 7 } }));
875 let args = merge_base_args(&matches, &cli_args, Some(&config)).unwrap();
876
877 assert_eq!(args.random_seed, 0);
878 }
879
880 #[test]
881 fn test_merge_custom_args_uses_config_values() {
882 let mut cli = create_ixa_cli();
883 cli = CustomArgs::augment_args(cli);
884 let matches = cli.try_get_matches_from(["ixa"]).unwrap();
885 let cli_args = CustomArgs::from_arg_matches(&matches).unwrap();
886 let config = json_object(json!({ "custom": { "a": 7 } }));
887 let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap();
888
889 assert_eq!(args.a, 7);
890 }
891
892 #[test]
893 fn test_merge_custom_args_cli_overrides_config_values() {
894 let mut cli = create_ixa_cli();
895 cli = CustomArgs::augment_args(cli);
896 let matches = cli.try_get_matches_from(["ixa", "--a", "9"]).unwrap();
897 let cli_args = CustomArgs::from_arg_matches(&matches).unwrap();
898 let config = json_object(json!({ "custom": { "a": 7 } }));
899 let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap();
900
901 assert_eq!(args.a, 9);
902 }
903
904 #[test]
905 fn test_merge_custom_args_preserves_clap_defaults() {
906 let (matches, cli_args) = parse_custom_args_from::<CustomArgsWithClapDefault, 1>(["ixa"]);
907 let args = merge_custom_args(&matches, &cli_args, None).unwrap();
908
909 assert_eq!(args.count, 10);
910 }
911
912 #[test]
913 fn test_merge_custom_args_allows_required_args_from_config() {
914 let (matches, cli_args) = parse_custom_args_from::<RequiredCustomArgs, 1>(["ixa"]);
915 let config = json_object(json!({ "custom": { "path": "from-config.json" } }));
916 let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap();
917
918 assert_eq!(args.path, PathBuf::from("from-config.json"));
919 }
920
921 #[test]
922 fn test_merge_custom_args_rejects_missing_required_args() {
923 let (matches, cli_args) = parse_custom_args_from::<RequiredCustomArgs, 1>(["ixa"]);
924 let err = merge_custom_args(&matches, &cli_args, None).unwrap_err();
925
926 assert!(matches!(
927 err,
928 IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom"
929 ));
930 }
931
932 #[test]
933 fn test_merge_custom_args_rejects_malformed_custom_section() {
934 let mut cli = create_ixa_cli();
935 cli = CustomArgs::augment_args(cli);
936 let matches = cli.try_get_matches_from(["ixa"]).unwrap();
937 let cli_args = CustomArgs::from_arg_matches(&matches).unwrap();
938 let config = json_object(json!({ "custom": 7 }));
939 let err = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap_err();
940
941 assert!(matches!(
942 err,
943 IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom"
944 ));
945 }
946
947 #[derive(Args, Debug, Default, Serialize, Deserialize)]
948 struct RenamedCustomArgs {
949 #[arg(long)]
950 #[serde(rename = "renamed")]
951 original: Option<String>,
952 }
953
954 #[test]
955 fn test_merge_custom_args_serde_renamed_field() {
956 let (matches, cli_args) = parse_custom_args_from::<RenamedCustomArgs, 1>(["ixa"]);
957
958 let args = merge_custom_args(&matches, &cli_args, None).unwrap();
959 assert_eq!(args.original, None);
960
961 let config = json_object(json!({ "custom": { "renamed": "from-config" } }));
962 let args = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap();
963 assert_eq!(args.original, Some("from-config".to_string()));
964 }
965
966 #[test]
967 fn test_merge_custom_args_rejects_base_arg_key() {
968 let (matches, cli_args) = parse_custom_args_from::<CustomArgs, 1>(["ixa"]);
969 let config = json_object(json!({ "custom": { "random_seed": 5, "a": 7 } }));
970 let err = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap_err();
971
972 assert!(matches!(
973 err,
974 IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom"
975 ));
976 }
977
978 #[test]
979 fn test_merge_custom_args_rejects_unknown_field() {
980 let (matches, cli_args) = parse_custom_args_from::<CustomArgs, 1>(["ixa"]);
981 let config = json_object(json!({ "custom": { "typo": 1 } }));
982 let err = merge_custom_args(&matches, &cli_args, Some(&config)).unwrap_err();
983
984 assert!(matches!(
985 err,
986 IxaError::InvalidRunnerConfig { section, .. } if section == "args.custom"
987 ));
988 }
989
990 #[test]
991 fn test_merge_base_args_rejects_config_key() {
992 let (matches, cli_args) = parse_base_args_from(["ixa"]);
993 let config = json_object(json!({ "config": "other.json" }));
994 let err = merge_base_args(&matches, &cli_args, Some(&config)).unwrap_err();
995
996 assert!(matches!(
997 err,
998 IxaError::InvalidRunnerConfig { section, .. } if section == "args"
999 ));
1000 }
1001
1002 #[test]
1003 fn test_run_with_config_rejects_unmerged_custom_args() {
1004 let temp_dir = tempdir().unwrap();
1005 let config_path = temp_dir.path().join("config.json");
1006 fs::write(&config_path, r#"{ "args": { "custom": { "a": 1 } } }"#).unwrap();
1007
1008 let test_args = BaseArgs {
1009 config: Some(config_path),
1010 ..Default::default()
1011 };
1012 let err = run_with_args_internal(test_args, None, |_, _, _: Option<()>| Ok(()))
1013 .err()
1014 .unwrap();
1015 assert!(err.to_string().contains("args.custom"));
1016 }
1017
1018 #[test]
1019 fn test_run_with_custom_args() {
1020 let result =
1021 run_with_args_internal(BaseArgs::new(), Some(CustomArgs::default()), |_, _, _| {
1022 Ok(())
1023 });
1024 assert!(result.is_ok());
1025 }
1026
1027 #[test]
1028 fn test_run_with_args() {
1029 let result = run_with_args_internal(BaseArgs::new(), None, |_, _, _: Option<()>| Ok(()));
1030 assert!(result.is_ok());
1031 }
1032
1033 #[test]
1034 fn test_run_with_random_seed() {
1035 let test_args = BaseArgs {
1036 random_seed: 42,
1037 ..Default::default()
1038 };
1039
1040 let mut compare_ctx = Context::new();
1042 compare_ctx.init_random(42);
1043 define_rng!(TestRng);
1044 let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
1045 assert_eq!(
1046 ctx.sample_range(TestRng, 0..100),
1047 compare_ctx.sample_range(TestRng, 0..100)
1048 );
1049 Ok(())
1050 });
1051 assert!(result.is_ok());
1052 }
1053
1054 #[derive(Serialize, Deserialize)]
1055 pub struct RunnerPropertyType {
1056 field_int: u32,
1057 }
1058 define_global_property!(RunnerProperty, RunnerPropertyType);
1059
1060 #[test]
1061 fn test_run_with_config_path() {
1062 let test_args = BaseArgs {
1063 config: Some(fixture_path("global_properties_runner.json")),
1064 ..Default::default()
1065 };
1066 let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
1067 let p3 = ctx.get_global_property_value(RunnerProperty).unwrap();
1068 assert_eq!(p3.field_int, 0);
1069 Ok(())
1070 });
1071 assert!(result.is_ok());
1072 }
1073
1074 #[test]
1075 fn test_run_with_config_path_ignores_args_for_global_properties() {
1076 let temp_dir = tempdir().unwrap();
1077 let config_path = temp_dir.path().join("config.json");
1078 fs::write(
1079 &config_path,
1080 r#"{
1081 "args": {
1082 "random_seed": 42
1083 },
1084 "ixa.RunnerProperty": {
1085 "field_int": 7
1086 }
1087 }
1088 "#,
1089 )
1090 .unwrap();
1091
1092 let test_args = BaseArgs {
1093 config: Some(config_path),
1094 ..Default::default()
1095 };
1096 let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
1097 let property = ctx.get_global_property_value(RunnerProperty).unwrap();
1098 assert_eq!(property.field_int, 7);
1099 Ok(())
1100 });
1101 assert!(result.is_ok());
1102 }
1103
1104 #[test]
1105 fn test_run_with_report_options() {
1106 let test_args = BaseArgs {
1107 output_dir: Some(PathBuf::from("data")),
1108 file_prefix: Some("test".to_string()),
1109 force_overwrite: true,
1110 ..Default::default()
1111 };
1112 let result = run_with_args_internal(test_args, None, |ctx, _, _: Option<()>| {
1113 let opts = &ctx.report_options();
1114 assert_eq!(opts.output_dir, PathBuf::from("data"));
1115 assert_eq!(opts.file_prefix, "test".to_string());
1116 assert!(opts.overwrite);
1117 Ok(())
1118 });
1119 assert!(result.is_ok());
1120 }
1121
1122 #[test]
1123 fn test_run_with_custom() {
1124 let test_args = BaseArgs::new();
1125 let custom = CustomArgs { a: 42 };
1126 let result = run_with_args_internal(test_args, Some(custom), |_, _, c| {
1127 assert_eq!(c.unwrap().a, 42);
1128 Ok(())
1129 });
1130 assert!(result.is_ok());
1131 }
1132
1133 #[test]
1134 fn test_run_with_logging_enabled() {
1135 let mut test_args = BaseArgs::new();
1136 test_args.log_level = Some(LevelFilter::Info.to_string());
1137 let result = run_with_args_internal(test_args, None, |_, _, _: Option<()>| Ok(()));
1138 assert!(result.is_ok());
1139 }
1140}