Skip to main content

ixa/
runner.rs

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
20/// Custom parser for log levels
21fn 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/// Default cli arguments for Ixa runner
40#[derive(Args, Debug, Clone, Serialize, Deserialize)]
41pub struct BaseArgs {
42    #[cfg(feature = "write_cli_usage")]
43    /// Print help in Markdown format. This is enabled only for debug builds. Run an example with
44    /// `--markdown-help`, and the file `docs/book/src/cli-usage.md` will be written. This file is then
45    /// included in the crate-level docs. See `src/lib.rs`.
46    #[arg(long, hide = true)]
47    markdown_help: bool,
48
49    /// Random seed
50    #[arg(short, long, default_value = "0")]
51    pub random_seed: u64,
52
53    /// Optional path for a global properties config file
54    #[arg(short, long)]
55    pub config: Option<PathBuf>,
56
57    /// Optional path for report output
58    #[arg(short, long = "output")]
59    pub output_dir: Option<PathBuf>,
60
61    /// Optional prefix for report files
62    #[arg(long = "prefix")]
63    pub file_prefix: Option<String>,
64
65    /// Overwrite existing report files?
66    #[arg(short, long)]
67    pub force_overwrite: bool,
68
69    /// Enable logging
70    #[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    /// Set logging to WARN level. Shortcut for `--log-level warn`.
89    #[arg(long)]
90    pub warn: bool,
91
92    /// Set logging to DEBUG level. Shortcut for `--log-level DEBUG`.
93    #[arg(long)]
94    pub debug: bool,
95
96    /// Set logging to TRACE level. Shortcut for `--log-level TRACE`.
97    #[arg(long)]
98    pub trace: bool,
99
100    /// Suppresses the printout of summary statistics at the end of the simulation.
101    #[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/// Effective runner arguments after merging defaults, config, and CLI values.
135#[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
184/// Entry points that don't merge custom args must reject `args.custom` rather
185/// than silently ignoring it.
186fn 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
216/// Base args that can never be set from the config file: `config` locates the
217/// config file itself and `markdown_help` is a debug-only doc generator.
218const CLI_ONLY_ARG_IDS: [&str; 2] = ["config", "markdown_help"];
219
220/// Base args whose CLI values bypass JSON serialization (paths may not be UTF-8).
221const 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    // CLI logging flags are shortcuts for each other, so an explicit one
280    // replaces all config-sourced logging fields as a group.
281    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    // PathBuf args can hold non-UTF8 bytes that JSON can't represent, so they
288    // are copied directly instead of round-tripped through serialization.
289    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    // A field whose serde name is not a clap arg id (e.g. `#[serde(rename)]`)
363    // can't be checked with value_source, so the config value wins for it.
364    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
433/// Runs a simulation with custom cli arguments.
434///
435/// This function allows you to define custom arguments and a setup function
436///
437/// # Parameters
438/// - `setup_fn`: A function that takes a mutable reference to a [`Context`], a [`BaseArgs`] struct,
439///   a `Option<A>` where `A` is the custom cli arguments struct
440///
441/// # Errors
442/// Returns an error if config loading/merging or the setup function fails.
443/// Invalid command line arguments print an error and exit the process.
444pub 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
463/// Runs a simulation with default cli arguments
464///
465/// This function parses command line arguments allows you to define a setup function
466///
467/// # Parameters
468/// - `setup_fn`: A function that takes a mutable reference to a [`Context`] and [`BaseArgs`] struct
469///
470/// # Errors
471/// Returns an error if config loading/merging or the setup function fails.
472/// Invalid command line arguments print an error and exit the process.
473pub 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
490/// Runs a simulation with merged base and custom arguments from CLI and config.
491///
492/// Values in `args` from the JSON config override defaults. Explicit CLI flags
493/// override config values. Custom config values are read from `args.custom`.
494/// Custom merging supports top-level serde fields whose names match clap arg
495/// IDs. A field whose serde name differs from its clap arg id (e.g. via
496/// `#[serde(rename)]` or `#[serde(flatten)]`) can still be set from the
497/// config, but an explicit CLI value for it cannot be detected, so the config
498/// value takes precedence for that field.
499/// `config` itself is CLI-only and is not read from the config file.
500///
501/// Custom args are merged by round-tripping through JSON, so custom path
502/// arguments given on the command line must be valid UTF-8. Base path
503/// arguments (`--config`, `--output`) have no such restriction.
504///
505/// # Errors
506/// Returns an error if config merging or the setup function fails. Invalid
507/// command line arguments print an error and exit the process.
508pub 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    // Print help/version and exit on parse errors, matching `Command::get_matches`.
516    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    // Output help to a markdown file
564    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    // Instantiate a context
588    let mut context = Context::new();
589
590    // Optionally set global properties from a file
591    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    // Configure report options
598    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    // The default log level. We process the arguments first and then set the log level once.
610    // We use the _maximum_ log level set by the user arguments if multiple log level flags
611    // are provided.
612    let mut current_log_level = crate::log::DEFAULT_LOG_LEVEL;
613
614    // Explicitly setting the log level takes precedence over `-v`-style verbosity.
615    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                        // Here you can set the log level for each key-value pair as needed
627                    }
628                }
629                Err(e) => return Err(Box::new(e)),
630            }
631        }
632    }
633
634    // Process `-v`-style verbosity arguments.
635    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    // Process "shortcut" log level arguments `--warn`, `--debug`, `--trace`.
645    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    // Tell the user what log level they have enabled.
656    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    // Finally, set the log level to the computed max.
670    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    // Run the provided Fn
686    setup_fn(&mut context, args)?;
687
688    // Execute the context
689    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        // Use a comparison context to verify the random seed was set
1041        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}