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::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 = 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}