Skip to main content

sim_run_core/
args.rs

1use std::{ffi::OsString, path::PathBuf};
2
3use crate::{CliBoot, CliError, LibSourceSpec};
4
5/// Top-level command selected by the bootloader parser.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum CliCommand {
8    /// Print the bootloader help text and exit.
9    Help,
10    /// Print the binary version and exit.
11    Version,
12    /// Load libraries and hand off to a loaded entrypoint.
13    Boot(CliBoot),
14}
15
16/// Parses the minimal bootloader flags.
17///
18/// The first argument is treated as the program name and skipped, matching
19/// `std::env::args_os`. An empty argument list selects [`CliCommand::Help`].
20///
21/// # Examples
22///
23/// ```
24/// use sim_run_core::{parse_args, CliCommand};
25///
26/// assert_eq!(parse_args(["sim", "--version"]).unwrap(), CliCommand::Version);
27///
28/// let CliCommand::Boot(boot) = parse_args(["sim", "--codec", "json"]).unwrap() else {
29///     panic!("expected a boot command");
30/// };
31/// assert_eq!(boot.codec.as_deref(), Some("json"));
32/// ```
33pub fn parse_args<I, S>(args: I) -> Result<CliCommand, CliError>
34where
35    I: IntoIterator<Item = S>,
36    S: Into<OsString>,
37{
38    let mut args = args.into_iter().map(Into::into).collect::<Vec<_>>();
39    if !args.is_empty() {
40        args.remove(0);
41    }
42    if args.is_empty() {
43        return Ok(CliCommand::Help);
44    }
45
46    let mut boot = CliBoot::default();
47    let mut cursor = 0;
48    while cursor < args.len() {
49        let arg = arg_string(&args[cursor]);
50        match arg.as_str() {
51            "--" => {
52                boot.payload.args.extend(args.drain(cursor + 1..));
53                break;
54            }
55            "--help" | "-h" | "help" => return Ok(CliCommand::Help),
56            "--version" | "-V" | "version" => return Ok(CliCommand::Version),
57            "--list" => {
58                boot.list = true;
59                cursor += 1;
60            }
61            "--codec" => {
62                boot.codec = set_once(boot.codec, "--codec", take_value(&args, &mut cursor)?)?;
63            }
64            "--load" => {
65                let source = take_value(&args, &mut cursor)?;
66                boot.loads.push(source.parse::<LibSourceSpec>()?);
67            }
68            "--native-audio-provider" => {
69                let source = take_value(&args, &mut cursor)?;
70                boot.native_audio_provider = set_once(
71                    boot.native_audio_provider,
72                    "--native-audio-provider",
73                    Box::new(source.parse::<LibSourceSpec>()?),
74                )?;
75            }
76            "--inspect" => {
77                boot.inspect =
78                    set_once(boot.inspect, "--inspect", take_value(&args, &mut cursor)?)?;
79            }
80            "--eval" => {
81                boot.payload.eval =
82                    set_once(boot.payload.eval, "--eval", take_value(&args, &mut cursor)?)?;
83            }
84            "--script" => {
85                let script = PathBuf::from(take_value(&args, &mut cursor)?);
86                boot.payload.script = set_once(boot.payload.script, "--script", script)?;
87            }
88            "--stdin" => {
89                boot.payload.stdin = set_once(
90                    boot.payload.stdin,
91                    "--stdin",
92                    take_value(&args, &mut cursor)?,
93                )?;
94            }
95            _ if arg.starts_with("--codec=") => {
96                boot.codec = set_once(boot.codec, "--codec", inline_value(&arg, "--codec=")?)?;
97                cursor += 1;
98            }
99            _ if arg.starts_with("--load=") => {
100                boot.loads
101                    .push(inline_value(&arg, "--load=")?.parse::<LibSourceSpec>()?);
102                cursor += 1;
103            }
104            _ if arg.starts_with("--native-audio-provider=") => {
105                boot.native_audio_provider = set_once(
106                    boot.native_audio_provider,
107                    "--native-audio-provider",
108                    Box::new(
109                        inline_value(&arg, "--native-audio-provider=")?.parse::<LibSourceSpec>()?,
110                    ),
111                )?;
112                cursor += 1;
113            }
114            _ if arg.starts_with("--inspect=") => {
115                boot.inspect =
116                    set_once(boot.inspect, "--inspect", inline_value(&arg, "--inspect=")?)?;
117                cursor += 1;
118            }
119            _ if arg.starts_with("--eval=") => {
120                boot.payload.eval =
121                    set_once(boot.payload.eval, "--eval", inline_value(&arg, "--eval=")?)?;
122                cursor += 1;
123            }
124            _ if arg.starts_with("--script=") => {
125                let script = PathBuf::from(inline_value(&arg, "--script=")?);
126                boot.payload.script = set_once(boot.payload.script, "--script", script)?;
127                cursor += 1;
128            }
129            _ if arg.starts_with("--stdin=") => {
130                boot.payload.stdin = set_once(
131                    boot.payload.stdin,
132                    "--stdin",
133                    inline_value(&arg, "--stdin=")?,
134                )?;
135                cursor += 1;
136            }
137            _ if !arg.starts_with('-') => {
138                boot.payload.args.extend(args.drain(cursor..));
139                break;
140            }
141            _ if arg.starts_with('-') => return Err(CliError::unsupported(&arg)),
142            _ => return Err(CliError::unsupported(&arg)),
143        }
144    }
145
146    Ok(CliCommand::Boot(boot))
147}
148
149fn take_value(args: &[OsString], cursor: &mut usize) -> Result<String, CliError> {
150    let flag = arg_string(&args[*cursor]);
151    let Some(value) = args.get(*cursor + 1) else {
152        return Err(CliError::missing_value(&flag));
153    };
154    *cursor += 2;
155    Ok(arg_string(value))
156}
157
158fn inline_value(arg: &str, prefix: &str) -> Result<String, CliError> {
159    let value = &arg[prefix.len()..];
160    if value.is_empty() {
161        Err(CliError::missing_value(prefix.trim_end_matches('=')))
162    } else {
163        Ok(value.to_owned())
164    }
165}
166
167fn set_once<T>(slot: Option<T>, flag: &str, value: T) -> Result<Option<T>, CliError> {
168    if slot.is_some() {
169        Err(CliError::duplicate(flag))
170    } else {
171        Ok(Some(value))
172    }
173}
174
175fn arg_string(arg: &OsString) -> String {
176    arg.to_string_lossy().into_owned()
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::{Payload, source::LibSourceSpec};
183
184    #[test]
185    fn parses_boot_flags_and_repeated_loads() {
186        let parsed = parse_args([
187            "sim",
188            "--codec",
189            "json",
190            "--load",
191            "symbol:codec/json",
192            "--load=path:./lib.wasm",
193            "--native-audio-provider=symbol:audio/provider/jack",
194            "--list",
195            "--inspect",
196            "codec/json",
197            "--eval",
198            "(+ 1 2)",
199            "--script=demo.sim",
200            "--stdin",
201            "input",
202        ])
203        .unwrap();
204
205        let CliCommand::Boot(boot) = parsed else {
206            panic!("expected boot command");
207        };
208        assert_eq!(boot.codec, Some("json".to_owned()));
209        assert_eq!(
210            boot.loads,
211            vec![
212                LibSourceSpec::Symbol("codec/json".to_owned()),
213                LibSourceSpec::Path(PathBuf::from("./lib.wasm")),
214            ]
215        );
216        assert_eq!(
217            boot.native_audio_provider.as_deref(),
218            Some(&LibSourceSpec::Symbol("audio/provider/jack".to_owned()))
219        );
220        assert!(boot.list);
221        assert_eq!(boot.inspect, Some("codec/json".to_owned()));
222        assert_eq!(
223            boot.payload,
224            Payload {
225                args: Vec::new(),
226                eval: Some("(+ 1 2)".to_owned()),
227                script: Some(PathBuf::from("demo.sim")),
228                stdin: Some("input".to_owned()),
229            }
230        );
231    }
232
233    #[test]
234    fn payload_after_double_dash_is_preserved_and_not_rejected() {
235        let parsed = parse_args(["sim", "--codec=lisp", "--", "run", "--flag", "value"]).unwrap();
236        let CliCommand::Boot(boot) = parsed else {
237            panic!("expected boot command");
238        };
239        assert_eq!(
240            boot.payload.args,
241            vec![
242                OsString::from("run"),
243                OsString::from("--flag"),
244                OsString::from("value"),
245            ]
246        );
247        assert_eq!(boot.envelope().verb, Some("run".to_owned()));
248    }
249
250    #[test]
251    fn parses_native_audio_provider_opt_in() {
252        let parsed =
253            parse_args(["sim", "--native-audio-provider=path:./jack-provider.so"]).unwrap();
254        let CliCommand::Boot(boot) = parsed else {
255            panic!("expected boot command");
256        };
257        assert_eq!(
258            boot.native_audio_provider.as_deref(),
259            Some(&LibSourceSpec::Path(PathBuf::from("./jack-provider.so")))
260        );
261    }
262
263    #[test]
264    fn first_payload_token_starts_loaded_lib_handoff() {
265        let parsed = parse_args(["sim", "--load=host:demo", "run", "--flag"]).unwrap();
266        let CliCommand::Boot(boot) = parsed else {
267            panic!("expected boot command");
268        };
269        assert_eq!(
270            boot.payload.args,
271            vec![OsString::from("run"), OsString::from("--flag")]
272        );
273        assert_eq!(boot.envelope().verb, Some("run".to_owned()));
274    }
275
276    #[test]
277    fn rejects_missing_duplicate_and_unknown_flags() {
278        assert_eq!(
279            parse_args(["sim", "--codec"]).unwrap_err().to_string(),
280            "--codec requires a value"
281        );
282        assert_eq!(
283            parse_args(["sim", "--codec=lisp", "--codec=json"])
284                .unwrap_err()
285                .to_string(),
286            "--codec was provided more than once"
287        );
288        assert_eq!(
289            parse_args(["sim", "--unknown"]).unwrap_err().to_string(),
290            "unsupported argument: --unknown"
291        );
292    }
293}