1use std::{ffi::OsString, path::PathBuf};
2
3use crate::{CliBoot, CliError, LibSourceSpec};
4
5#[derive(Clone, Debug, PartialEq, Eq)]
7pub enum CliCommand {
8 Help,
10 Version,
12 Boot(CliBoot),
14}
15
16pub 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}