1use std::{ffi::OsString, path::PathBuf};
2
3use crate::{
4 CliBoot, CliError, ConfigReportKind, ConfigReportRequest, LibSourceSpec,
5 source::{parse_source_os, 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 = match arg_text(&args[cursor], "argument flag") {
54 Ok(arg) => arg.to_owned(),
55 Err(_) if is_positional_os_arg(&args[cursor]) => {
56 boot.payload.args.extend(args.drain(cursor..));
57 break;
58 }
59 Err(err) => return Err(err),
60 };
61 match arg.as_str() {
62 "--" => {
63 boot.payload.args.extend(args.drain(cursor + 1..));
64 break;
65 }
66 "--help" | "-h" | "help" => return Ok(CliCommand::Help),
67 "--version" | "-V" | "version" => return Ok(CliCommand::Version),
68 "--list" => {
69 boot.list = true;
70 cursor += 1;
71 }
72 "--codec" => {
73 boot.codec = set_once(boot.codec, "--codec", take_text_value(&args, &mut cursor)?)?;
74 }
75 "--load" => {
76 let source = take_os_value(&args, &mut cursor)?;
77 boot.loads.push(parse_source_os(source)?);
78 }
79 "--native-audio-provider" => {
80 let source = take_os_value(&args, &mut cursor)?;
81 boot.native_audio_provider = set_once(
82 boot.native_audio_provider,
83 "--native-audio-provider",
84 Box::new(parse_source_os(source)?),
85 )?;
86 }
87 "--config-home" => {
88 reject_seen(&mut seen.home, "--config-home")?;
89 boot.config.roots.home = Some(PathBuf::from(take_os_value(&args, &mut cursor)?));
90 }
91 "--config-work" => {
92 reject_seen(&mut seen.work, "--config-work")?;
93 boot.config.roots.work = PathBuf::from(take_os_value(&args, &mut cursor)?);
94 }
95 "--config-file" => {
96 boot.config.single_file = set_once(
97 boot.config.single_file,
98 "--config-file",
99 PathBuf::from(take_os_value(&args, &mut cursor)?),
100 )?;
101 }
102 "--config-site" => {
103 let site = symbol_from_text(&take_text_value(&args, &mut cursor)?);
104 boot.config.site_sources.push(site);
105 }
106 "--no-config-files" => {
107 reject_seen(&mut seen.no_files, "--no-config-files")?;
108 boot.config.read_files = false;
109 cursor += 1;
110 }
111 "--inspect" => {
112 boot.inspect = set_once(
113 boot.inspect,
114 "--inspect",
115 take_text_value(&args, &mut cursor)?,
116 )?;
117 }
118 "--eval" => {
119 boot.payload.eval = set_once(
120 boot.payload.eval,
121 "--eval",
122 take_text_value(&args, &mut cursor)?,
123 )?;
124 }
125 "--script" => {
126 let script = PathBuf::from(take_os_value(&args, &mut cursor)?);
127 boot.payload.script = set_once(boot.payload.script, "--script", script)?;
128 }
129 "--stdin" => {
130 boot.payload.stdin = set_once(
131 boot.payload.stdin,
132 "--stdin",
133 take_text_value(&args, &mut cursor)?,
134 )?;
135 }
136 _ if arg.starts_with("--codec=") => {
137 boot.codec = set_once(boot.codec, "--codec", inline_value(&arg, "--codec=")?)?;
138 cursor += 1;
139 }
140 _ if arg.starts_with("--load=") => {
141 boot.loads
142 .push(inline_value(&arg, "--load=")?.parse::<LibSourceSpec>()?);
143 cursor += 1;
144 }
145 _ if arg.starts_with("--native-audio-provider=") => {
146 boot.native_audio_provider = set_once(
147 boot.native_audio_provider,
148 "--native-audio-provider",
149 Box::new(
150 inline_value(&arg, "--native-audio-provider=")?.parse::<LibSourceSpec>()?,
151 ),
152 )?;
153 cursor += 1;
154 }
155 _ if arg.starts_with("--config-home=") => {
156 reject_seen(&mut seen.home, "--config-home")?;
157 boot.config.roots.home = Some(PathBuf::from(inline_value(&arg, "--config-home=")?));
158 cursor += 1;
159 }
160 _ if arg.starts_with("--config-work=") => {
161 reject_seen(&mut seen.work, "--config-work")?;
162 boot.config.roots.work = PathBuf::from(inline_value(&arg, "--config-work=")?);
163 cursor += 1;
164 }
165 _ if arg.starts_with("--config-file=") => {
166 boot.config.single_file = set_once(
167 boot.config.single_file,
168 "--config-file",
169 PathBuf::from(inline_value(&arg, "--config-file=")?),
170 )?;
171 cursor += 1;
172 }
173 _ if arg.starts_with("--config-site=") => {
174 let site = symbol_from_text(&inline_value(&arg, "--config-site=")?);
175 boot.config.site_sources.push(site);
176 cursor += 1;
177 }
178 _ if arg.starts_with("--inspect=") => {
179 boot.inspect =
180 set_once(boot.inspect, "--inspect", inline_value(&arg, "--inspect=")?)?;
181 cursor += 1;
182 }
183 _ if arg.starts_with("--eval=") => {
184 boot.payload.eval =
185 set_once(boot.payload.eval, "--eval", inline_value(&arg, "--eval=")?)?;
186 cursor += 1;
187 }
188 _ if arg.starts_with("--script=") => {
189 let script = PathBuf::from(inline_value(&arg, "--script=")?);
190 boot.payload.script = set_once(boot.payload.script, "--script", script)?;
191 cursor += 1;
192 }
193 _ if arg.starts_with("--stdin=") => {
194 boot.payload.stdin = set_once(
195 boot.payload.stdin,
196 "--stdin",
197 inline_value(&arg, "--stdin=")?,
198 )?;
199 cursor += 1;
200 }
201 _ if !arg.starts_with('-') => {
202 if arg == "config" {
203 boot.config_report = Some(parse_config_report(&args[cursor + 1..])?);
204 break;
205 }
206 boot.payload.args.extend(args.drain(cursor..));
207 break;
208 }
209 _ if arg.starts_with('-') => return Err(CliError::unsupported(&arg)),
210 _ => return Err(CliError::unsupported(&arg)),
211 }
212 }
213
214 Ok(CliCommand::Boot(Box::new(boot)))
215}
216
217fn take_text_value(args: &[OsString], cursor: &mut usize) -> Result<String, CliError> {
218 let flag = arg_text(&args[*cursor], "argument flag")?.to_owned();
219 let Some(value) = args.get(*cursor + 1) else {
220 return Err(CliError::missing_value(&flag));
221 };
222 *cursor += 2;
223 Ok(arg_text(value, &flag)?.to_owned())
224}
225
226fn take_os_value(args: &[OsString], cursor: &mut usize) -> Result<OsString, CliError> {
227 let flag = arg_text(&args[*cursor], "argument flag")?.to_owned();
228 let Some(value) = args.get(*cursor + 1) else {
229 return Err(CliError::missing_value(&flag));
230 };
231 *cursor += 2;
232 Ok(value.clone())
233}
234
235fn inline_value(arg: &str, prefix: &str) -> Result<String, CliError> {
236 let value = &arg[prefix.len()..];
237 if value.is_empty() {
238 Err(CliError::missing_value(prefix.trim_end_matches('=')))
239 } else {
240 Ok(value.to_owned())
241 }
242}
243
244fn set_once<T>(slot: Option<T>, flag: &str, value: T) -> Result<Option<T>, CliError> {
245 if slot.is_some() {
246 Err(CliError::duplicate(flag))
247 } else {
248 Ok(Some(value))
249 }
250}
251
252#[derive(Default)]
253struct ConfigFlagsSeen {
254 home: bool,
255 work: bool,
256 no_files: bool,
257}
258
259fn reject_seen(seen: &mut bool, flag: &str) -> Result<(), CliError> {
260 if *seen {
261 Err(CliError::duplicate(flag))
262 } else {
263 *seen = true;
264 Ok(())
265 }
266}
267
268fn parse_config_report(args: &[OsString]) -> Result<ConfigReportRequest, CliError> {
269 let mut json = false;
270 let mut positionals = Vec::new();
271 for arg in args {
272 let arg = arg_text(arg, "config command argument")?.to_owned();
273 match arg.as_str() {
274 "--json" => {
275 if json {
276 return Err(CliError::duplicate("--json"));
277 }
278 json = true;
279 }
280 "--" => return Err(CliError::unsupported("--")),
281 _ if arg.starts_with('-') => return Err(CliError::unsupported(&arg)),
282 _ => positionals.push(arg),
283 }
284 }
285 let Some(command) = positionals.first().map(String::as_str) else {
286 return Err(CliError::new(
287 "config requires one of: status, effective, sources",
288 ));
289 };
290 let kind = match command {
291 "status" if positionals.len() == 1 => ConfigReportKind::Status,
292 "sources" if positionals.len() == 1 => ConfigReportKind::Sources,
293 "effective" if positionals.len() == 2 => ConfigReportKind::Effective {
294 lib: symbol_from_text(&positionals[1]),
295 },
296 "effective" => {
297 return Err(CliError::new(
298 "config effective requires exactly one library id",
299 ));
300 }
301 _ => return Err(CliError::unsupported(&format!("config {command}"))),
302 };
303 Ok(ConfigReportRequest { kind, json })
304}
305
306fn arg_text<'a>(arg: &'a OsString, context: &str) -> Result<&'a str, CliError> {
307 arg.as_os_str()
308 .to_str()
309 .ok_or_else(|| CliError::new(format!("{context} requires UTF-8 text")))
310}
311
312fn is_positional_os_arg(arg: &OsString) -> bool {
313 #[cfg(unix)]
314 {
315 use std::os::unix::ffi::OsStrExt;
316
317 !arg.as_os_str().as_bytes().starts_with(b"-")
318 }
319
320 #[cfg(not(unix))]
321 {
322 arg.as_os_str()
323 .to_str()
324 .is_some_and(|arg| !arg.starts_with('-'))
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::{Payload, source::LibSourceSpec};
332 #[cfg(unix)]
333 use std::os::unix::ffi::{OsStrExt, OsStringExt};
334
335 #[test]
336 fn parses_boot_flags_and_repeated_loads() {
337 let parsed = parse_args([
338 "sim",
339 "--codec",
340 "json",
341 "--load",
342 "symbol:codec/json",
343 "--load=path:./lib.wasm",
344 "--native-audio-provider=symbol:audio/provider/jack",
345 "--config-home",
346 "/tmp/sim-home",
347 "--config-work=/tmp/sim-work",
348 "--config-file",
349 "/tmp/sim.toml",
350 "--config-site=config/runtime",
351 "--list",
352 "--inspect",
353 "codec/json",
354 "--eval",
355 "(+ 1 2)",
356 "--script=demo.sim",
357 "--stdin",
358 "input",
359 ])
360 .unwrap();
361
362 let CliCommand::Boot(boot) = parsed else {
363 panic!("expected boot command");
364 };
365 assert_eq!(boot.codec, Some("json".to_owned()));
366 assert_eq!(
367 boot.loads,
368 vec![
369 LibSourceSpec::Symbol("codec/json".to_owned()),
370 LibSourceSpec::Path(PathBuf::from("./lib.wasm")),
371 ]
372 );
373 assert_eq!(
374 boot.native_audio_provider.as_deref(),
375 Some(&LibSourceSpec::Symbol("audio/provider/jack".to_owned()))
376 );
377 assert_eq!(boot.config.roots.home, Some(PathBuf::from("/tmp/sim-home")));
378 assert_eq!(boot.config.roots.work, PathBuf::from("/tmp/sim-work"));
379 assert_eq!(
380 boot.config.single_file,
381 Some(PathBuf::from("/tmp/sim.toml"))
382 );
383 assert_eq!(
384 boot.config.site_sources,
385 vec![sim_kernel::Symbol::qualified("config", "runtime")]
386 );
387 assert!(boot.list);
388 assert_eq!(boot.inspect, Some("codec/json".to_owned()));
389 assert_eq!(
390 boot.payload,
391 Payload {
392 args: Vec::new(),
393 eval: Some("(+ 1 2)".to_owned()),
394 script: Some(PathBuf::from("demo.sim")),
395 stdin: Some("input".to_owned()),
396 }
397 );
398 }
399
400 #[test]
401 fn payload_after_double_dash_is_preserved_and_not_rejected() {
402 let parsed = parse_args(["sim", "--codec=lisp", "--", "run", "--flag", "value"]).unwrap();
403 let CliCommand::Boot(boot) = parsed else {
404 panic!("expected boot command");
405 };
406 assert_eq!(
407 boot.payload.args,
408 vec![
409 OsString::from("run"),
410 OsString::from("--flag"),
411 OsString::from("value"),
412 ]
413 );
414 assert_eq!(boot.envelope().verb, Some("run".to_owned()));
415 }
416
417 #[test]
418 fn parses_native_audio_provider_opt_in() {
419 let parsed =
420 parse_args(["sim", "--native-audio-provider=path:./jack-provider.so"]).unwrap();
421 let CliCommand::Boot(boot) = parsed else {
422 panic!("expected boot command");
423 };
424 assert_eq!(
425 boot.native_audio_provider.as_deref(),
426 Some(&LibSourceSpec::Path(PathBuf::from("./jack-provider.so")))
427 );
428 }
429
430 #[cfg(unix)]
431 #[test]
432 fn load_path_value_preserves_non_utf8_bytes() {
433 let parsed = parse_args(vec![
434 OsString::from("sim"),
435 OsString::from("--load"),
436 OsString::from_vec(b"path:/tmp/sim-run-\xff-loader.wasm".to_vec()),
437 ])
438 .unwrap();
439 let CliCommand::Boot(boot) = parsed else {
440 panic!("expected boot command");
441 };
442 let LibSourceSpec::Path(path) = &boot.loads[0] else {
443 panic!("expected path source");
444 };
445
446 assert_eq!(
447 path.as_os_str().as_bytes(),
448 b"/tmp/sim-run-\xff-loader.wasm"
449 );
450 }
451
452 #[cfg(unix)]
453 #[test]
454 fn script_path_value_preserves_non_utf8_bytes() {
455 let parsed = parse_args(vec![
456 OsString::from("sim"),
457 OsString::from("--script"),
458 OsString::from_vec(b"/tmp/sim-run-\xff-script.sim".to_vec()),
459 ])
460 .unwrap();
461 let CliCommand::Boot(boot) = parsed else {
462 panic!("expected boot command");
463 };
464 let path = boot.payload.script.as_ref().unwrap();
465
466 assert_eq!(path.as_os_str().as_bytes(), b"/tmp/sim-run-\xff-script.sim");
467 }
468
469 #[cfg(unix)]
470 #[test]
471 fn non_utf8_text_value_fails_closed() {
472 let err = parse_args(vec![
473 OsString::from("sim"),
474 OsString::from("--codec"),
475 OsString::from_vec(b"li\xffsp".to_vec()),
476 ])
477 .unwrap_err();
478
479 assert_eq!(err.to_string(), "--codec requires UTF-8 text");
480 }
481
482 #[cfg(unix)]
483 #[test]
484 fn non_utf8_positional_payload_is_preserved() {
485 let payload = OsString::from_vec(b"run-\xff".to_vec());
486 let parsed = parse_args(vec![OsString::from("sim"), payload.clone()]).unwrap();
487 let CliCommand::Boot(boot) = parsed else {
488 panic!("expected boot command");
489 };
490
491 assert_eq!(boot.payload.args, vec![payload]);
492 assert_eq!(boot.envelope().verb, None);
493 }
494
495 #[test]
496 fn first_payload_token_starts_loaded_lib_handoff() {
497 let parsed = parse_args(["sim", "--load=host:demo", "run", "--flag"]).unwrap();
498 let CliCommand::Boot(boot) = parsed else {
499 panic!("expected boot command");
500 };
501 assert_eq!(
502 boot.payload.args,
503 vec![OsString::from("run"), OsString::from("--flag")]
504 );
505 assert_eq!(boot.envelope().verb, Some("run".to_owned()));
506 }
507
508 #[test]
509 fn parses_config_report_commands() {
510 let parsed = parse_args(["sim", "--config-file=sim.toml", "config", "status"]).unwrap();
511 let CliCommand::Boot(boot) = parsed else {
512 panic!("expected boot command");
513 };
514 assert_eq!(
515 boot.config_report,
516 Some(ConfigReportRequest {
517 kind: ConfigReportKind::Status,
518 json: false,
519 })
520 );
521
522 let parsed = parse_args(["sim", "config", "effective", "sim/cookbook", "--json"]).unwrap();
523 let CliCommand::Boot(boot) = parsed else {
524 panic!("expected boot command");
525 };
526 assert_eq!(
527 boot.config_report,
528 Some(ConfigReportRequest {
529 kind: ConfigReportKind::Effective {
530 lib: sim_kernel::Symbol::qualified("sim", "cookbook"),
531 },
532 json: true,
533 })
534 );
535
536 let parsed = parse_args(["sim", "config", "sources", "--json"]).unwrap();
537 let CliCommand::Boot(boot) = parsed else {
538 panic!("expected boot command");
539 };
540 assert_eq!(
541 boot.config_report,
542 Some(ConfigReportRequest {
543 kind: ConfigReportKind::Sources,
544 json: true,
545 })
546 );
547 }
548
549 #[test]
550 fn rejects_missing_duplicate_and_unknown_flags() {
551 assert_eq!(
552 parse_args(["sim", "--codec"]).unwrap_err().to_string(),
553 "--codec requires a value"
554 );
555 assert_eq!(
556 parse_args(["sim", "--codec=lisp", "--codec=json"])
557 .unwrap_err()
558 .to_string(),
559 "--codec was provided more than once"
560 );
561 assert_eq!(
562 parse_args(["sim", "--unknown"]).unwrap_err().to_string(),
563 "unsupported argument: --unknown"
564 );
565 assert_eq!(
566 parse_args(["sim", "--config-file"])
567 .unwrap_err()
568 .to_string(),
569 "--config-file requires a value"
570 );
571 assert_eq!(
572 parse_args(["sim", "--config-work=a", "--config-work=b"])
573 .unwrap_err()
574 .to_string(),
575 "--config-work was provided more than once"
576 );
577 assert_eq!(
578 parse_args(["sim", "config"]).unwrap_err().to_string(),
579 "config requires one of: status, effective, sources"
580 );
581 assert_eq!(
582 parse_args(["sim", "config", "effective"])
583 .unwrap_err()
584 .to_string(),
585 "config effective requires exactly one library id"
586 );
587 }
588}