1use std::env;
2use std::path::PathBuf;
3
4pub struct CliOptions {
5 pub scenario: Option<PathBuf>,
6 pub preset: Option<String>,
7 pub telemetry_out: Option<PathBuf>,
8 pub api_bind: Option<String>,
9}
10
11pub fn parse_args() -> Result<CliOptions, String> {
12 let args: Vec<String> = env::args().skip(1).collect();
13 parse_args_from(args)
14}
15
16fn parse_args_from(args: Vec<String>) -> Result<CliOptions, String> {
17 if args.len() == 1 && (args[0] == "--help" || args[0] == "-h") {
18 print_usage();
19 std::process::exit(0);
20 }
21 parse_options(&args)
22}
23
24fn parse_options(args: &[String]) -> Result<CliOptions, String> {
25 let mut i = 0usize;
26 let mut scenario = None;
27 let mut preset = None;
28 let mut telemetry_out = None;
29 let mut api_bind = None;
30
31 while i < args.len() {
32 match args[i].as_str() {
33 "--scenario" => {
34 i += 1;
35 let path = args.get(i).ok_or_else(|| {
36 "missing value for --scenario (expected a .toml scenario file path)".to_string()
37 })?;
38 if scenario.replace(PathBuf::from(path)).is_some() {
39 return Err("--scenario provided more than once".to_string());
40 }
41 }
42 "--preset" => {
43 i += 1;
44 let name = args.get(i).ok_or_else(|| {
45 "missing value for --preset (expected a preset name)".to_string()
46 })?;
47 if preset.replace(name.clone()).is_some() {
48 return Err("--preset provided more than once".to_string());
49 }
50 }
51 "--telemetry-out" => {
52 i += 1;
53 let path = args.next_or_err(
54 i,
55 "missing value for --telemetry-out (expected a file path)",
56 )?;
57 if telemetry_out.replace(PathBuf::from(path)).is_some() {
58 return Err("--telemetry-out provided more than once".to_string());
59 }
60 }
61 "--api-bind" => {
62 i += 1;
63 let addr =
64 args.next_or_err(i, "missing value for --api-bind (expected host:port)")?;
65 if api_bind.replace(addr.to_string()).is_some() {
66 return Err("--api-bind provided more than once".to_string());
67 }
68 }
69 "--help" | "-h" => {
70 print_usage();
71 std::process::exit(0);
72 }
73 other => return Err(format!("unknown argument: {other}")),
74 }
75 i += 1;
76 }
77
78 if scenario.is_some() && preset.is_some() {
79 return Err(
80 "arguments `--scenario` and `--preset` are mutually exclusive; choose one source"
81 .to_string(),
82 );
83 }
84
85 if scenario.is_none() && preset.is_none() {
86 preset = Some("demo".to_string());
87 }
88
89 Ok(CliOptions {
90 scenario,
91 preset,
92 telemetry_out,
93 api_bind,
94 })
95}
96
97trait SliceArgExt {
98 fn next_or_err(&self, index: usize, err: &str) -> Result<&str, String>;
99}
100
101impl SliceArgExt for [String] {
102 fn next_or_err(&self, index: usize, err: &str) -> Result<&str, String> {
103 self.get(index)
104 .map(String::as_str)
105 .ok_or_else(|| err.to_string())
106 }
107}
108
109pub fn print_usage() {
110 eprintln!("Usage:");
111 eprintln!(
112 " cargo run --release -- [--scenario <path> | --preset <name>] [--telemetry-out <path>] [--api-bind <host:port>]"
113 );
114}
115
116#[cfg(test)]
117mod tests {
118 use super::parse_args_from;
119
120 #[test]
121 fn supports_scenario_cli() {
122 let opts = parse_args_from(vec!["--scenario".to_string(), "scenario.json".to_string()])
123 .expect("parse should succeed");
124 assert_eq!(
125 opts.scenario.as_deref().and_then(|p| p.to_str()),
126 Some("scenario.json")
127 );
128 assert!(opts.preset.is_none());
129 }
130
131 #[test]
132 fn supports_preset_cli() {
133 let opts = parse_args_from(vec!["--preset".to_string(), "demo".to_string()])
134 .expect("parse should succeed");
135 assert_eq!(opts.preset.as_deref(), Some("demo"));
136 assert!(opts.scenario.is_none());
137 }
138
139 #[test]
140 fn supports_api_bind_cli() {
141 let opts = parse_args_from(vec![
142 "--preset".to_string(),
143 "demo".to_string(),
144 "--api-bind".to_string(),
145 "127.0.0.1:8080".to_string(),
146 ])
147 .expect("parse should succeed");
148 assert_eq!(opts.api_bind.as_deref(), Some("127.0.0.1:8080"));
149 }
150}