1use std::{ffi::OsString, path::PathBuf};
2
3use crate::{
4 CliBoot, CliError, ConfigReportKind, ConfigReportRequest, LibSourceSpec,
5 source::symbol_from_text,
6};
7
8#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum CliCommand {
11 Help,
13 Version,
15 Boot(Box<CliBoot>),
17}
18
19pub fn parse_args<I, S>(args: I) -> Result<CliCommand, CliError>
37where
38 I: IntoIterator<Item = S>,
39 S: Into<OsString>,
40{
41 let mut args = args.into_iter().map(Into::into).collect::<Vec<_>>();
42 if !args.is_empty() {
43 args.remove(0);
44 }
45 if args.is_empty() {
46 return Ok(CliCommand::Help);
47 }
48
49 let mut boot = CliBoot::default();
50 let mut seen = ConfigFlagsSeen::default();
51 let mut cursor = 0;
52 while cursor < args.len() {
53 let arg = arg_string(&args[cursor]);
54 match arg.as_str() {
55 "--" => {
56 boot.payload.args.extend(args.drain(cursor + 1..));
57 break;
58 }
59 "--help" | "-h" | "help" => return Ok(CliCommand::Help),
60 "--version" | "-V" | "version" => return Ok(CliCommand::Version),
61 "--list" => {
62 boot.list = true;
63 cursor += 1;
64 }
65 "--codec" => {
66 boot.codec = set_once(boot.codec, "--codec", take_value(&args, &mut cursor)?)?;
67 }
68 "--load" => {
69 let source = take_value(&args, &mut cursor)?;
70 boot.loads.push(source.parse::<LibSourceSpec>()?);
71 }
72 "--native-audio-provider" => {
73 let source = take_value(&args, &mut cursor)?;
74 boot.native_audio_provider = set_once(
75 boot.native_audio_provider,
76 "--native-audio-provider",
77 Box::new(source.parse::<LibSourceSpec>()?),
78 )?;
79 }
80 "--config-home" => {
81 reject_seen(&mut seen.home, "--config-home")?;
82 boot.config.roots.home = Some(PathBuf::from(take_value(&args, &mut cursor)?));
83 }
84 "--config-work" => {
85 reject_seen(&mut seen.work, "--config-work")?;
86 boot.config.roots.work = PathBuf::from(take_value(&args, &mut cursor)?);
87 }
88 "--config-file" => {
89 boot.config.single_file = set_once(
90 boot.config.single_file,
91 "--config-file",
92 PathBuf::from(take_value(&args, &mut cursor)?),
93 )?;
94 }
95 "--config-site" => {
96 let site = symbol_from_text(&take_value(&args, &mut cursor)?);
97 boot.config.site_sources.push(site);
98 }
99 "--no-config-files" => {
100 reject_seen(&mut seen.no_files, "--no-config-files")?;
101 boot.config.read_files = false;
102 cursor += 1;
103 }
104 "--inspect" => {
105 boot.inspect =
106 set_once(boot.inspect, "--inspect", take_value(&args, &mut cursor)?)?;
107 }
108 "--eval" => {
109 boot.payload.eval =
110 set_once(boot.payload.eval, "--eval", take_value(&args, &mut cursor)?)?;
111 }
112 "--script" => {
113 let script = PathBuf::from(take_value(&args, &mut cursor)?);
114 boot.payload.script = set_once(boot.payload.script, "--script", script)?;
115 }
116 "--stdin" => {
117 boot.payload.stdin = set_once(
118 boot.payload.stdin,
119 "--stdin",
120 take_value(&args, &mut cursor)?,
121 )?;
122 }
123 _ if arg.starts_with("--codec=") => {
124 boot.codec = set_once(boot.codec, "--codec", inline_value(&arg, "--codec=")?)?;
125 cursor += 1;
126 }
127 _ if arg.starts_with("--load=") => {
128 boot.loads
129 .push(inline_value(&arg, "--load=")?.parse::<LibSourceSpec>()?);
130 cursor += 1;
131 }
132 _ if arg.starts_with("--native-audio-provider=") => {
133 boot.native_audio_provider = set_once(
134 boot.native_audio_provider,
135 "--native-audio-provider",
136 Box::new(
137 inline_value(&arg, "--native-audio-provider=")?.parse::<LibSourceSpec>()?,
138 ),
139 )?;
140 cursor += 1;
141 }
142 _ if arg.starts_with("--config-home=") => {
143 reject_seen(&mut seen.home, "--config-home")?;
144 boot.config.roots.home = Some(PathBuf::from(inline_value(&arg, "--config-home=")?));
145 cursor += 1;
146 }
147 _ if arg.starts_with("--config-work=") => {
148 reject_seen(&mut seen.work, "--config-work")?;
149 boot.config.roots.work = PathBuf::from(inline_value(&arg, "--config-work=")?);
150 cursor += 1;
151 }
152 _ if arg.starts_with("--config-file=") => {
153 boot.config.single_file = set_once(
154 boot.config.single_file,
155 "--config-file",
156 PathBuf::from(inline_value(&arg, "--config-file=")?),
157 )?;
158 cursor += 1;
159 }
160 _ if arg.starts_with("--config-site=") => {
161 let site = symbol_from_text(&inline_value(&arg, "--config-site=")?);
162 boot.config.site_sources.push(site);
163 cursor += 1;
164 }
165 _ if arg.starts_with("--inspect=") => {
166 boot.inspect =
167 set_once(boot.inspect, "--inspect", inline_value(&arg, "--inspect=")?)?;
168 cursor += 1;
169 }
170 _ if arg.starts_with("--eval=") => {
171 boot.payload.eval =
172 set_once(boot.payload.eval, "--eval", inline_value(&arg, "--eval=")?)?;
173 cursor += 1;
174 }
175 _ if arg.starts_with("--script=") => {
176 let script = PathBuf::from(inline_value(&arg, "--script=")?);
177 boot.payload.script = set_once(boot.payload.script, "--script", script)?;
178 cursor += 1;
179 }
180 _ if arg.starts_with("--stdin=") => {
181 boot.payload.stdin = set_once(
182 boot.payload.stdin,
183 "--stdin",
184 inline_value(&arg, "--stdin=")?,
185 )?;
186 cursor += 1;
187 }
188 _ if !arg.starts_with('-') => {
189 if arg == "config" {
190 boot.config_report = Some(parse_config_report(&args[cursor + 1..])?);
191 break;
192 }
193 boot.payload.args.extend(args.drain(cursor..));
194 break;
195 }
196 _ if arg.starts_with('-') => return Err(CliError::unsupported(&arg)),
197 _ => return Err(CliError::unsupported(&arg)),
198 }
199 }
200
201 Ok(CliCommand::Boot(Box::new(boot)))
202}
203
204fn take_value(args: &[OsString], cursor: &mut usize) -> Result<String, CliError> {
205 let flag = arg_string(&args[*cursor]);
206 let Some(value) = args.get(*cursor + 1) else {
207 return Err(CliError::missing_value(&flag));
208 };
209 *cursor += 2;
210 Ok(arg_string(value))
211}
212
213fn inline_value(arg: &str, prefix: &str) -> Result<String, CliError> {
214 let value = &arg[prefix.len()..];
215 if value.is_empty() {
216 Err(CliError::missing_value(prefix.trim_end_matches('=')))
217 } else {
218 Ok(value.to_owned())
219 }
220}
221
222fn set_once<T>(slot: Option<T>, flag: &str, value: T) -> Result<Option<T>, CliError> {
223 if slot.is_some() {
224 Err(CliError::duplicate(flag))
225 } else {
226 Ok(Some(value))
227 }
228}
229
230#[derive(Default)]
231struct ConfigFlagsSeen {
232 home: bool,
233 work: bool,
234 no_files: bool,
235}
236
237fn reject_seen(seen: &mut bool, flag: &str) -> Result<(), CliError> {
238 if *seen {
239 Err(CliError::duplicate(flag))
240 } else {
241 *seen = true;
242 Ok(())
243 }
244}
245
246fn parse_config_report(args: &[OsString]) -> Result<ConfigReportRequest, CliError> {
247 let mut json = false;
248 let mut positionals = Vec::new();
249 for arg in args {
250 let arg = arg_string(arg);
251 match arg.as_str() {
252 "--json" => {
253 if json {
254 return Err(CliError::duplicate("--json"));
255 }
256 json = true;
257 }
258 "--" => return Err(CliError::unsupported("--")),
259 _ if arg.starts_with('-') => return Err(CliError::unsupported(&arg)),
260 _ => positionals.push(arg),
261 }
262 }
263 let Some(command) = positionals.first().map(String::as_str) else {
264 return Err(CliError::new(
265 "config requires one of: status, effective, sources",
266 ));
267 };
268 let kind = match command {
269 "status" if positionals.len() == 1 => ConfigReportKind::Status,
270 "sources" if positionals.len() == 1 => ConfigReportKind::Sources,
271 "effective" if positionals.len() == 2 => ConfigReportKind::Effective {
272 lib: symbol_from_text(&positionals[1]),
273 },
274 "effective" => {
275 return Err(CliError::new(
276 "config effective requires exactly one library id",
277 ));
278 }
279 _ => return Err(CliError::unsupported(&format!("config {command}"))),
280 };
281 Ok(ConfigReportRequest { kind, json })
282}
283
284fn arg_string(arg: &OsString) -> String {
285 arg.to_string_lossy().into_owned()
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use crate::{Payload, source::LibSourceSpec};
292
293 #[test]
294 fn parses_boot_flags_and_repeated_loads() {
295 let parsed = parse_args([
296 "sim",
297 "--codec",
298 "json",
299 "--load",
300 "symbol:codec/json",
301 "--load=path:./lib.wasm",
302 "--native-audio-provider=symbol:audio/provider/jack",
303 "--config-home",
304 "/tmp/sim-home",
305 "--config-work=/tmp/sim-work",
306 "--config-file",
307 "/tmp/sim.toml",
308 "--config-site=config/runtime",
309 "--list",
310 "--inspect",
311 "codec/json",
312 "--eval",
313 "(+ 1 2)",
314 "--script=demo.sim",
315 "--stdin",
316 "input",
317 ])
318 .unwrap();
319
320 let CliCommand::Boot(boot) = parsed else {
321 panic!("expected boot command");
322 };
323 assert_eq!(boot.codec, Some("json".to_owned()));
324 assert_eq!(
325 boot.loads,
326 vec![
327 LibSourceSpec::Symbol("codec/json".to_owned()),
328 LibSourceSpec::Path(PathBuf::from("./lib.wasm")),
329 ]
330 );
331 assert_eq!(
332 boot.native_audio_provider.as_deref(),
333 Some(&LibSourceSpec::Symbol("audio/provider/jack".to_owned()))
334 );
335 assert_eq!(boot.config.roots.home, Some(PathBuf::from("/tmp/sim-home")));
336 assert_eq!(boot.config.roots.work, PathBuf::from("/tmp/sim-work"));
337 assert_eq!(
338 boot.config.single_file,
339 Some(PathBuf::from("/tmp/sim.toml"))
340 );
341 assert_eq!(
342 boot.config.site_sources,
343 vec![sim_kernel::Symbol::qualified("config", "runtime")]
344 );
345 assert!(boot.list);
346 assert_eq!(boot.inspect, Some("codec/json".to_owned()));
347 assert_eq!(
348 boot.payload,
349 Payload {
350 args: Vec::new(),
351 eval: Some("(+ 1 2)".to_owned()),
352 script: Some(PathBuf::from("demo.sim")),
353 stdin: Some("input".to_owned()),
354 }
355 );
356 }
357
358 #[test]
359 fn payload_after_double_dash_is_preserved_and_not_rejected() {
360 let parsed = parse_args(["sim", "--codec=lisp", "--", "run", "--flag", "value"]).unwrap();
361 let CliCommand::Boot(boot) = parsed else {
362 panic!("expected boot command");
363 };
364 assert_eq!(
365 boot.payload.args,
366 vec![
367 OsString::from("run"),
368 OsString::from("--flag"),
369 OsString::from("value"),
370 ]
371 );
372 assert_eq!(boot.envelope().verb, Some("run".to_owned()));
373 }
374
375 #[test]
376 fn parses_native_audio_provider_opt_in() {
377 let parsed =
378 parse_args(["sim", "--native-audio-provider=path:./jack-provider.so"]).unwrap();
379 let CliCommand::Boot(boot) = parsed else {
380 panic!("expected boot command");
381 };
382 assert_eq!(
383 boot.native_audio_provider.as_deref(),
384 Some(&LibSourceSpec::Path(PathBuf::from("./jack-provider.so")))
385 );
386 }
387
388 #[test]
389 fn first_payload_token_starts_loaded_lib_handoff() {
390 let parsed = parse_args(["sim", "--load=host:demo", "run", "--flag"]).unwrap();
391 let CliCommand::Boot(boot) = parsed else {
392 panic!("expected boot command");
393 };
394 assert_eq!(
395 boot.payload.args,
396 vec![OsString::from("run"), OsString::from("--flag")]
397 );
398 assert_eq!(boot.envelope().verb, Some("run".to_owned()));
399 }
400
401 #[test]
402 fn parses_config_report_commands() {
403 let parsed = parse_args(["sim", "--config-file=sim.toml", "config", "status"]).unwrap();
404 let CliCommand::Boot(boot) = parsed else {
405 panic!("expected boot command");
406 };
407 assert_eq!(
408 boot.config_report,
409 Some(ConfigReportRequest {
410 kind: ConfigReportKind::Status,
411 json: false,
412 })
413 );
414
415 let parsed = parse_args(["sim", "config", "effective", "sim/cookbook", "--json"]).unwrap();
416 let CliCommand::Boot(boot) = parsed else {
417 panic!("expected boot command");
418 };
419 assert_eq!(
420 boot.config_report,
421 Some(ConfigReportRequest {
422 kind: ConfigReportKind::Effective {
423 lib: sim_kernel::Symbol::qualified("sim", "cookbook"),
424 },
425 json: true,
426 })
427 );
428
429 let parsed = parse_args(["sim", "config", "sources", "--json"]).unwrap();
430 let CliCommand::Boot(boot) = parsed else {
431 panic!("expected boot command");
432 };
433 assert_eq!(
434 boot.config_report,
435 Some(ConfigReportRequest {
436 kind: ConfigReportKind::Sources,
437 json: true,
438 })
439 );
440 }
441
442 #[test]
443 fn rejects_missing_duplicate_and_unknown_flags() {
444 assert_eq!(
445 parse_args(["sim", "--codec"]).unwrap_err().to_string(),
446 "--codec requires a value"
447 );
448 assert_eq!(
449 parse_args(["sim", "--codec=lisp", "--codec=json"])
450 .unwrap_err()
451 .to_string(),
452 "--codec was provided more than once"
453 );
454 assert_eq!(
455 parse_args(["sim", "--unknown"]).unwrap_err().to_string(),
456 "unsupported argument: --unknown"
457 );
458 assert_eq!(
459 parse_args(["sim", "--config-file"])
460 .unwrap_err()
461 .to_string(),
462 "--config-file requires a value"
463 );
464 assert_eq!(
465 parse_args(["sim", "--config-work=a", "--config-work=b"])
466 .unwrap_err()
467 .to_string(),
468 "--config-work was provided more than once"
469 );
470 assert_eq!(
471 parse_args(["sim", "config"]).unwrap_err().to_string(),
472 "config requires one of: status, effective, sources"
473 );
474 assert_eq!(
475 parse_args(["sim", "config", "effective"])
476 .unwrap_err()
477 .to_string(),
478 "config effective requires exactly one library id"
479 );
480 }
481}