Skip to main content

sim_run_core/
args.rs

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/// Top-level command selected by the bootloader parser.
9#[derive(Clone, Debug, PartialEq, Eq)]
10pub enum CliCommand {
11    /// Print the bootloader help text and exit.
12    Help,
13    /// Print the binary version and exit.
14    Version,
15    /// Load libraries and hand off to a loaded entrypoint.
16    Boot(Box<CliBoot>),
17}
18
19/// Parses the minimal bootloader flags.
20///
21/// The first argument is treated as the program name and skipped, matching
22/// `std::env::args_os`. An empty argument list selects [`CliCommand::Help`].
23///
24/// # Examples
25///
26/// ```
27/// use sim_run_core::{parse_args, CliCommand};
28///
29/// assert_eq!(parse_args(["sim", "--version"]).unwrap(), CliCommand::Version);
30///
31/// let CliCommand::Boot(boot) = parse_args(["sim", "--codec", "json"]).unwrap() else {
32///     panic!("expected a boot command");
33/// };
34/// assert_eq!(boot.codec.as_deref(), Some("json"));
35/// ```
36pub 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}