Skip to main content

simular/cli/
args.rs

1//! CLI argument parsing.
2//!
3//! This module provides the argument parser for the simular CLI.
4//! Extracted to enable comprehensive testing of argument parsing logic.
5
6use std::path::PathBuf;
7
8/// CLI arguments container.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Args {
11    /// The command to execute.
12    pub command: Command,
13}
14
15/// Available CLI commands.
16#[derive(Debug, Clone, PartialEq)]
17pub enum Command {
18    /// Run an experiment
19    Run {
20        /// Path to the experiment YAML file.
21        experiment_path: PathBuf,
22        /// Optional seed override.
23        seed_override: Option<u64>,
24        /// Enable verbose output.
25        verbose: bool,
26    },
27    /// Render simulation to SVG + keyframes
28    Render {
29        /// Simulation domain (orbit, `monte_carlo`, optimization).
30        domain: String,
31        /// Output format: svg-frames or svg-keyframes.
32        format: RenderFormat,
33        /// Output directory.
34        output: PathBuf,
35        /// Frames per second.
36        fps: u32,
37        /// Simulation duration in seconds.
38        duration: f64,
39        /// Random seed for deterministic output.
40        seed: u64,
41    },
42    /// Validate experiment YAML against EDD v2 schema
43    Validate {
44        /// Path to the experiment YAML file.
45        experiment_path: PathBuf,
46    },
47    /// Verify reproducibility of an experiment
48    Verify {
49        /// Path to the experiment YAML file.
50        experiment_path: PathBuf,
51        /// Number of verification runs.
52        runs: usize,
53    },
54    /// Check EMC compliance
55    EmcCheck {
56        /// Path to the experiment YAML file.
57        experiment_path: PathBuf,
58    },
59    /// Validate an EMC YAML file against EDD v2 EMC schema
60    EmcValidate {
61        /// Path to the EMC file.
62        emc_path: PathBuf,
63    },
64    /// List available EMCs in the library
65    ListEmc,
66    /// Show help
67    Help,
68    /// Show version
69    Version,
70    /// Parse error (missing args, unknown command)
71    Error(String),
72}
73
74/// SVG render output format.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum RenderFormat {
77    /// One SVG file per frame.
78    SvgFrames,
79    /// One template SVG + keyframes JSON.
80    SvgKeyframes,
81}
82
83impl Args {
84    /// Parse command-line arguments from an iterator.
85    ///
86    /// This method is testable as it accepts any iterator of strings,
87    /// not just `std::env::args()`.
88    #[must_use]
89    pub fn parse_from<I, S>(args: I) -> Self
90    where
91        I: IntoIterator<Item = S>,
92        S: AsRef<str>,
93    {
94        let args: Vec<String> = args.into_iter().map(|s| s.as_ref().to_string()).collect();
95        Self::parse_from_vec(&args)
96    }
97
98    /// Parse command-line arguments from the environment.
99    #[must_use]
100    pub fn parse() -> Self {
101        Self::parse_from(std::env::args())
102    }
103
104    /// Internal parsing from a vector of strings.
105    fn parse_from_vec(args: &[String]) -> Self {
106        if args.len() < 2 {
107            return Self {
108                command: Command::Help,
109            };
110        }
111
112        let command = match args[1].as_str() {
113            "run" => Self::parse_run_command(args),
114            "render" => Self::parse_render_command(args),
115            "validate" => Self::parse_validate_command(args),
116            "verify" => Self::parse_verify_command(args),
117            "emc-check" => Self::parse_emc_check_command(args),
118            "emc-validate" => Self::parse_emc_validate_command(args),
119            "list-emc" => Command::ListEmc,
120            "-h" | "--help" | "help" => Command::Help,
121            "-V" | "--version" | "version" => Command::Version,
122            unknown => Command::Error(format!("Unknown command: {unknown}")),
123        };
124
125        Self { command }
126    }
127
128    /// Check if a positional arg is a help flag.
129    fn is_help_flag(arg: &str) -> bool {
130        arg == "--help" || arg == "-h"
131    }
132
133    /// Parse the 'run' command arguments.
134    fn parse_run_command(args: &[String]) -> Command {
135        if args.len() < 3 || Self::is_help_flag(&args[2]) {
136            return Command::Help;
137        }
138
139        let mut seed_override = None;
140        let mut verbose = false;
141
142        let mut i = 3;
143        while i < args.len() {
144            match args[i].as_str() {
145                "--seed" => {
146                    if i + 1 < args.len() {
147                        if let Ok(seed) = args[i + 1].parse() {
148                            seed_override = Some(seed);
149                        }
150                        i += 2;
151                    } else {
152                        i += 1;
153                    }
154                }
155                "-v" | "--verbose" => {
156                    verbose = true;
157                    i += 1;
158                }
159                _ => i += 1,
160            }
161        }
162
163        Command::Run {
164            experiment_path: PathBuf::from(&args[2]),
165            seed_override,
166            verbose,
167        }
168    }
169
170    /// Parse the 'validate' command arguments.
171    fn parse_validate_command(args: &[String]) -> Command {
172        if args.len() < 3 || Self::is_help_flag(&args[2]) {
173            return Command::Help;
174        }
175
176        Command::Validate {
177            experiment_path: PathBuf::from(&args[2]),
178        }
179    }
180
181    /// Parse the 'verify' command arguments.
182    fn parse_verify_command(args: &[String]) -> Command {
183        if args.len() < 3 || Self::is_help_flag(&args[2]) {
184            return Command::Help;
185        }
186
187        let mut runs = 3;
188        if args.len() > 3 && args[3] == "--runs" && args.len() > 4 {
189            if let Ok(n) = args[4].parse() {
190                runs = n;
191            }
192        }
193
194        Command::Verify {
195            experiment_path: PathBuf::from(&args[2]),
196            runs,
197        }
198    }
199
200    /// Parse the 'emc-check' command arguments.
201    fn parse_emc_check_command(args: &[String]) -> Command {
202        if args.len() < 3 || Self::is_help_flag(&args[2]) {
203            return Command::Help;
204        }
205
206        Command::EmcCheck {
207            experiment_path: PathBuf::from(&args[2]),
208        }
209    }
210
211    /// Parse the 'emc-validate' command arguments.
212    fn parse_emc_validate_command(args: &[String]) -> Command {
213        if args.len() < 3 || Self::is_help_flag(&args[2]) {
214            return Command::Help;
215        }
216
217        Command::EmcValidate {
218            emc_path: PathBuf::from(&args[2]),
219        }
220    }
221
222    /// Collect all `--key value` pairs from args starting at position `start`.
223    fn collect_flags(args: &[String], start: usize) -> std::collections::HashMap<String, String> {
224        let mut flags = std::collections::HashMap::new();
225        let mut i = start;
226        while i < args.len() {
227            if args[i].starts_with("--") && i + 1 < args.len() {
228                flags.insert(args[i].clone(), args[i + 1].clone());
229                i += 2;
230            } else {
231                i += 1;
232            }
233        }
234        flags
235    }
236
237    /// Parse the 'render' command arguments.
238    fn parse_render_command(args: &[String]) -> Command {
239        if args.len() >= 3 && Self::is_help_flag(&args[2]) {
240            return Command::Help;
241        }
242        let flags = Self::collect_flags(args, 2);
243
244        let domain = flags
245            .get("--domain")
246            .cloned()
247            .unwrap_or_else(|| "orbit".to_string());
248        let format = match flags.get("--format").map(String::as_str) {
249            Some("svg-frames") => RenderFormat::SvgFrames,
250            _ => RenderFormat::SvgKeyframes,
251        };
252        let output = flags
253            .get("--output")
254            .map_or_else(|| PathBuf::from("."), PathBuf::from);
255        let fps = flags
256            .get("--fps")
257            .and_then(|v| v.parse().ok())
258            .unwrap_or(60);
259        let duration = flags
260            .get("--duration")
261            .and_then(|v| v.parse().ok())
262            .unwrap_or(10.0);
263        let seed = flags
264            .get("--seed")
265            .and_then(|v| v.parse().ok())
266            .unwrap_or(42);
267
268        Command::Render {
269            domain,
270            format,
271            output,
272            fps,
273            duration,
274            seed,
275        }
276    }
277}