1use std::path::PathBuf;
4
5use sva_core::{Asked, CliError};
6
7mod reading;
8
9pub(crate) use reading::check_frame;
10use reading::{analyze_args, render_args};
11
12pub const USAGE: &str = "usage: sva-cli render '<expression>' --representation <r>[=<path>][,...] \
13 [--until '<condition>'] [--bits <n>] [--rate <hz>] [--flop-budget <n>] [--cache <path|none>] \
14 [--confirm]\n \
15 sva-cli analyze <file.wav> --representation <r>[=<path>][,...] [--confirm]\n \
16 sva-cli lint ['<expression>'] [--format <json|text>]\n \
17 sva-cli trace <node|expression>\n \
18 sva-cli builtins\n \
19 sva-cli outline <expression>\n \
20 sva-cli new <name> [--idempotency-key <key>]\n\
21 a render's target is one expression; `@path` reads a node in the current directory, \
22 `@/abs/path` one anywhere, and its own ref may read an interval: `@piano([0, 2b], f0=C4)`\n\
23 representations: lines atoms spectrum(peaks, frame) envelope(frame) derivative samples \
24 ledger(depth, brief, skim) pitch(peaks, frame) formants(peaks, frame) stereo(frame) bands \
25 crest loudness alias(oversample) bindings(node) arguments flops\n\
26 analyses (`analyze` only): onsets trajectory masking(against) gain-reduction\n\
27 destinations: a `.wav` path takes `samples` as audio; any other path takes JSON; none \
28 puts the reading under `data.representations`\n\
29 verb aliases: `validate`=lint, `list`=builtins, `create`=new, `show`=trace. `render` \
30 and `analyze` take a reading, which the standard's verb list has no word for, so they \
31 keep their own names.";
32
33pub const ANALYZE_REPRESENTATIONS: [&str; 10] = [
35 "samples",
36 "spectrum",
37 "envelope",
38 "derivative",
39 "pitch",
40 "formants",
41 "bands",
42 "loudness",
43 "crest",
44 "stereo",
45];
46
47#[derive(Debug, PartialEq)]
48pub struct RenderArgs {
49 pub target: String,
51 pub until: Option<String>,
52 pub rate: Option<u32>,
53 pub bits: Option<i32>,
55 pub flop_budget: Option<u128>,
57 pub asked: Vec<Asked>,
58 pub confirm: bool,
60 pub cache: CacheAt,
61}
62
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
65pub enum CacheAt {
66 #[default]
67 Platform,
68 Path(PathBuf),
69 Off,
70}
71
72#[derive(Debug, PartialEq)]
74pub struct Analysis {
75 pub name: String,
76 pub dest: Option<PathBuf>,
77 pub against: Option<PathBuf>,
79}
80
81#[derive(Debug, PartialEq)]
82pub struct AnalyzeArgs {
83 pub path: PathBuf,
84 pub asked: Vec<Asked>,
85 pub analyses: Vec<Analysis>,
86 pub confirm: bool,
88}
89
90#[derive(Debug, PartialEq)]
91pub enum Command {
92 Version,
93 Help,
94 Render(Box<RenderArgs>),
95 Analyze(Box<AnalyzeArgs>),
96 Lint {
98 target: Option<String>,
99 format: Format,
100 },
101 Trace {
102 target: String,
103 },
104 Builtins,
105 Outline {
107 text: String,
108 },
109 New {
110 name: String,
111 idempotency_key: Option<String>,
114 },
115}
116
117#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
119pub enum Format {
120 #[default]
121 Json,
122 Text,
123}
124
125const STANDARD_VERBS: [(&str, &str); 4] = [
126 ("validate", "lint"),
127 ("list", "builtins"),
128 ("create", "new"),
129 ("show", "trace"),
130];
131
132fn canonical(subcommand: &str) -> &str {
133 STANDARD_VERBS
134 .iter()
135 .find(|(alias, _)| *alias == subcommand)
136 .map_or(subcommand, |(_, name)| *name)
137}
138
139pub(crate) fn value<'a>(
142 it: &mut impl Iterator<Item = &'a String>,
143 name: &str,
144) -> Result<String, CliError> {
145 it.next()
146 .cloned()
147 .ok_or_else(|| CliError::Usage(format!("{name} needs a value\n{USAGE}")))
148}
149
150pub fn parse_args(argv: &[String]) -> Result<Command, CliError> {
152 if argv.iter().any(|a| a == "--version" || a == "-V") {
153 return Ok(Command::Version);
154 }
155 if argv.iter().any(|a| a == "--help") {
156 return Ok(Command::Help);
157 }
158
159 let mut it = argv.iter();
160 let subcommand = canonical(
161 it.next()
162 .ok_or_else(|| CliError::Usage(format!("no subcommand given\n{USAGE}")))?,
163 );
164 let rest = it.as_slice();
165 match subcommand {
166 "render" => render_args(rest),
167 "analyze" => analyze_args(rest),
168 "lint" => lint_args(rest),
169 "trace" => trace_args(rest),
170 "builtins" => builtins_args(rest),
171 "outline" => outline_args(rest),
172 "new" => new_args(rest),
173 other => Err(CliError::Usage(format!(
174 "unknown subcommand `{other}`\n{USAGE}"
175 ))),
176 }
177}
178
179fn builtins_args(rest: &[String]) -> Result<Command, CliError> {
180 match rest.first() {
181 None => Ok(Command::Builtins),
182 Some(extra) => Err(CliError::Usage(format!(
183 "builtins takes no arguments, not `{extra}`\n{USAGE}"
184 ))),
185 }
186}
187
188fn outline_args(rest: &[String]) -> Result<Command, CliError> {
189 match rest {
190 [text] => Ok(Command::Outline { text: text.clone() }),
191 _ => Err(CliError::Usage(format!(
192 "outline takes one <expression>, quoted as one argument\n{USAGE}"
193 ))),
194 }
195}
196
197fn new_args(rest: &[String]) -> Result<Command, CliError> {
198 match rest {
199 [name] if !name.starts_with("--") => Ok(Command::New {
200 name: name.clone(),
201 idempotency_key: None,
202 }),
203 [name, flag, key] if !name.starts_with("--") && flag == "--idempotency-key" => {
204 Ok(Command::New {
205 name: name.clone(),
206 idempotency_key: Some(key.clone()),
207 })
208 }
209 [] => Err(CliError::Usage(format!("missing <name>\n{USAGE}"))),
210 _ => Err(CliError::Usage(format!(
211 "new takes one <name> and an optional `--idempotency-key <key>`\n{USAGE}"
212 ))),
213 }
214}
215
216fn lint_args(rest: &[String]) -> Result<Command, CliError> {
217 let mut it = rest.iter().peekable();
218 let target = match it.peek() {
219 Some(a) if !a.starts_with("--") => it.next().cloned(),
220 _ => None,
221 };
222 let mut format = Format::default();
223 while let Some(flag) = it.next() {
224 match flag.as_str() {
225 "--format" => {
226 format = match it.next().map(String::as_str) {
227 Some("json") => Format::Json,
228 Some("text") => Format::Text,
229 other => {
230 return Err(CliError::Usage(format!(
231 "`--format` takes json or text, not `{}`\n{USAGE}",
232 other.unwrap_or("nothing")
233 )));
234 }
235 };
236 }
237 extra => {
238 return Err(CliError::Usage(format!(
239 "lint takes only ['<expression>'] and `--format <json|text>`, not \
240 `{extra}`\n{USAGE}"
241 )));
242 }
243 }
244 }
245 Ok(Command::Lint { target, format })
246}
247
248fn trace_args(rest: &[String]) -> Result<Command, CliError> {
250 match rest {
251 [target] if !target.starts_with("--") => Ok(Command::Trace {
252 target: target.clone(),
253 }),
254 [] => Err(CliError::Usage(format!(
255 "missing <node|expression>\n{USAGE}"
256 ))),
257 _ => Err(CliError::Usage(format!(
258 "trace takes one <node|expression> and nothing else\n{USAGE}"
259 ))),
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::path::Path;
267
268 fn argv(parts: &[&str]) -> Vec<String> {
269 parts.iter().map(|s| (*s).to_string()).collect()
270 }
271
272 fn rendered(parts: &[&str]) -> RenderArgs {
273 match parse_args(&argv(parts)) {
274 Ok(Command::Render(args)) => *args,
275 other => panic!("expected a render, got {other:?}"),
276 }
277 }
278
279 fn refused(parts: &[&str]) -> String {
280 match parse_args(&argv(parts)) {
281 Err(CliError::Usage(message)) => message,
282 other => panic!("expected a usage refusal, got {other:?}"),
283 }
284 }
285
286 #[test]
287 fn version_and_help_are_recognized_anywhere_in_argv() {
288 assert_eq!(parse_args(&argv(&["--version"])).unwrap(), Command::Version);
289 assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Command::Version);
290 assert_eq!(
291 parse_args(&argv(&["lint", "--help"])).unwrap(),
292 Command::Help
293 );
294 }
295
296 #[test]
297 fn each_standard_verb_alias_reaches_the_subcommand_it_names() {
298 assert_eq!(
299 parse_args(&argv(&["validate"])).unwrap(),
300 Command::Lint {
301 target: None,
302 format: Format::Json,
303 }
304 );
305 assert_eq!(parse_args(&argv(&["list"])).unwrap(), Command::Builtins);
306 assert_eq!(
307 parse_args(&argv(&["show", "kick"])).unwrap(),
308 Command::Trace {
309 target: "kick".to_string(),
310 }
311 );
312 assert_eq!(
313 parse_args(&argv(&["create", "song1"])).unwrap(),
314 Command::New {
315 name: "song1".to_string(),
316 idempotency_key: None,
317 }
318 );
319 }
320
321 #[test]
324 fn a_render_takes_a_target_readings_with_their_arguments_and_flags() {
325 let args = rendered(&[
326 "render",
327 "@piano([0, 2b], f0=C4)",
328 "--representation",
329 "samples=/tmp/out.wav, spectrum(peaks=8, frame=50ms)",
330 "--representation",
331 "ledger(depth=2, brief=1)=/tmp/ledger.json,bindings(node=@voice)",
332 "--rate",
333 "48000",
334 "--bits",
335 "16",
336 "--flop-budget",
337 "1000",
338 "--until",
339 "t > 1s",
340 ]);
341 assert_eq!(args.target, "@piano([0, 2b], f0=C4)");
342 assert_eq!(args.until.as_deref(), Some("t > 1s"));
343 assert_eq!(args.rate, Some(48_000));
344 assert_eq!(args.bits, Some(16));
345 assert_eq!(args.flop_budget, Some(1000));
346 let names: Vec<&str> = args.asked.iter().map(|a| a.name.as_str()).collect();
347 assert_eq!(names, ["samples", "spectrum", "ledger", "bindings"]);
348 assert_eq!(
349 args.asked[0].dest.as_deref(),
350 Some(Path::new("/tmp/out.wav"))
351 );
352 assert_eq!(
353 args.asked[1].representation,
354 sva_engine::Representation::Spectrum {
355 max_peaks: 8,
356 frame_secs: Some(0.05)
357 }
358 );
359 assert_eq!(
360 args.asked[2].representation,
361 sva_engine::Representation::Ledger { depth: 2 }
362 );
363 assert!(args.asked[2].brief);
364 assert_eq!(
365 args.asked[2].dest.as_deref(),
366 Some(Path::new("/tmp/ledger.json"))
367 );
368 assert_eq!(args.asked[3].node.as_deref(), Some("voice"));
369 }
370
371 #[test]
372 fn a_target_may_open_with_a_minus() {
373 let args = rendered(&["render", "-1*sin(2*pi*220*t)", "--representation", "lines"]);
374 assert_eq!(args.target, "-1*sin(2*pi*220*t)");
375 }
376
377 #[test]
378 fn a_render_with_no_target_or_no_reading_refuses() {
379 refused(&["render"]);
380 refused(&["render", "--representation", "lines"]);
381 refused(&["render", "@a"]);
382 }
383
384 #[test]
386 fn a_retired_flag_refuses_by_name() {
387 for gone in [
388 "-c",
389 "--in",
390 "--from",
391 "--to",
392 "--max",
393 "--sample-rate",
394 "--as",
395 "--node",
396 "--frame",
397 "--depth",
398 "--peaks",
399 "--oversample",
400 "--brief",
401 "--skim",
402 "--pcm16",
403 ] {
404 let message = refused(&["render", "@a", "--representation", "lines", gone, "x"]);
405 assert!(message.contains(gone), "{gone}: {message}");
406 }
407 }
408
409 #[test]
410 fn an_argument_its_reading_does_not_take_or_a_malformed_one_refuses() {
411 for reading in [
412 "lines(depth=2)",
413 "ledger(colour=red)",
414 "ledger(depth)",
415 "ledger(depth=many)",
416 "ledger(brief=yes)",
417 "samples(bits=16)",
418 "spectrum(peaks=8",
419 "bindings(node=voice)",
420 ] {
421 refused(&["render", "@a", "--representation", reading]);
422 }
423 }
424
425 #[test]
426 fn a_representation_that_left_the_language_names_what_replaced_it() {
427 for (gone, write) in sva_core::RETIRED {
428 let message = refused(&["render", "@a", "--representation", gone]);
429 assert!(message.contains(write), "{message}");
430 }
431 }
432
433 #[test]
434 fn a_wav_destination_takes_samples_and_nothing_else() {
435 refused(&["render", "@a", "--representation", "ledger=/tmp/out.wav"]);
436 }
437
438 #[test]
439 fn bindings_needs_a_node() {
440 refused(&["render", "@a", "--representation", "bindings"]);
441 }
442
443 #[test]
444 fn lint_takes_an_optional_target_and_nothing_else() {
445 assert_eq!(
446 parse_args(&argv(&["lint", "@drums/kick"])).unwrap(),
447 Command::Lint {
448 target: Some("@drums/kick".to_string()),
449 format: Format::Json,
450 }
451 );
452 refused(&["lint", "a", "b"]);
453 refused(&["lint", "--in", "x"]);
454 }
455
456 #[test]
457 fn new_takes_a_name_and_an_optional_idempotency_key() {
458 assert_eq!(
459 parse_args(&argv(&["new", "song1", "--idempotency-key", "k"])).unwrap(),
460 Command::New {
461 name: "song1".to_string(),
462 idempotency_key: Some("k".to_string()),
463 }
464 );
465 refused(&["new"]);
466 }
467
468 #[test]
469 fn builtins_takes_no_arguments() {
470 assert_eq!(parse_args(&argv(&["builtins"])).unwrap(), Command::Builtins);
471 refused(&["builtins", "x"]);
472 }
473
474 #[test]
475 fn a_subcommand_this_cli_does_not_answer_refuses_by_name() {
476 for gone in ["bench", "nonlinear-solve"] {
477 assert!(refused(&[gone]).contains(gone));
478 }
479 }
480}