1use std::path::PathBuf;
4
5use sva_core::{Asked, CliError, WindowEdge};
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 [<node|expression>] [query options]\n \
13 sva-cli analyze <file.wav> [--as <representation>[=<destination>]]... [--from <time>] \
14 [--to <time>] [--frame <secs>] [--peaks <n>]\n \
15 sva-cli lint [<node|expression>] [--in <dir>] [--format <json|text>]\n \
16 sva-cli trace <node|expression> [--in <dir>]\n \
17 sva-cli builtins\n \
18 sva-cli outline <expression>\n \
19 sva-cli new <name> [--idempotency-key <key>]\n\
20 query options: [--in <dir>] [--node <path>] [--from <time>] [--to <time>] \
21 [--as <representation>[=<destination>]]... [--frame <secs>] [--depth <n>] [--peaks <n>] \
22 [--sample-rate <hz>] [--oversample <n>] [--flop-budget <n>] [--volatile <name>]... \
23 [--no-cache] [--brief] [--skim] [--pcm16] [--confirm]\n\
24 representations: lines atoms spectrum envelope derivative samples ledger pitch formants \
25 stereo bands crest loudness alias bindings arguments flops\n\
26 analyses (`analyze` only): onsets trajectory masking gain-reduction\n\
27 --against <file.wav> is the second signal `--as masking` is read against\n\
28 --sample-rate <hz> is the observation rate, legal with any --as\n\
29 --node <path> names the instance a reading is taken of; required with `--as bindings`\n\
30 --flop-budget <n> is the operation count the caller means to pay; the profile's own \
31 budget refuses past it, and `--as flops` prints the tree that count came from\n\
32 --volatile <name> declares a bound parameter the player is moving: every node that reads \
33 it is rendered and kept in memory for this run only, never written to the cache, and \
34 sounds the same as without the flag; a name the target binds nowhere is refused\n\
35 --brief condenses `ledger` to the nodes that clipped\n\
36 --skim condenses `ledger`'s fields to node/channel/rms/peak/clipped\n\
37 --pcm16 quantizes a `.wav` destination to 16-bit PCM instead of 32-bit float\n\
38 destinations: a `.wav` path takes `samples` as audio; any other path takes JSON; none \
39 prints JSON to stdout\n\
40 --in <dir> names the composition `render`, `lint` and `trace` read; without it they read \
41 the current directory. `new` and `analyze` take none. Each <node|expression> names a node \
42 path or an expression in the same expression grammar a node file's body holds. A file \
43 holds structure besides that body -- `name = <expr>` defaults, a TSV grid, a bare meter \
44 such as `4/4` -- and an argument is a body alone, so `4/4` there is a division.\n\
45 verb aliases: `validate`=lint, `list`=builtins, `create`=new, `show`=trace. `render` \
46 and `analyze` take a reading, which the standard's verb list has no word for, so they \
47 keep their own names.";
48
49pub const ANALYZE_REPRESENTATIONS: [&str; 10] = [
51 "samples",
52 "spectrum",
53 "envelope",
54 "derivative",
55 "pitch",
56 "formants",
57 "bands",
58 "loudness",
59 "crest",
60 "stereo",
61];
62
63#[derive(Debug, PartialEq)]
64pub struct RenderArgs {
65 pub target: Option<String>,
67 pub node: Option<String>,
69 pub dir: Option<String>,
71 pub cache: bool,
72 pub sample_rate: Option<u32>,
73 pub from: Option<WindowEdge>,
74 pub to: Option<WindowEdge>,
75 pub silent: Option<sva_core::Silent>,
77 pub asked: Vec<Asked>,
78 pub brief: bool,
79 pub skim: bool,
80 pub pcm16: bool,
81 pub confirm: bool,
83 pub flop_budget: Option<u128>,
84 pub volatile: Vec<String>,
85}
86
87#[derive(Debug, PartialEq)]
88pub struct AnalyzeArgs {
89 pub path: PathBuf,
90 pub from: Option<WindowEdge>,
91 pub to: Option<WindowEdge>,
92 pub asked: Vec<Asked>,
93 pub analyses: Vec<(String, Option<PathBuf>)>,
95 pub against: Option<PathBuf>,
96 pub confirm: bool,
98}
99
100#[derive(Debug, PartialEq)]
101pub enum Command {
102 Version,
103 Help,
104 Render(Box<RenderArgs>),
105 Analyze(Box<AnalyzeArgs>),
106 Lint {
109 target: Option<String>,
110 dir: Option<String>,
111 format: Format,
112 },
113 Trace {
114 target: String,
115 dir: Option<String>,
116 },
117 Builtins,
118 Outline {
120 text: String,
121 },
122 New {
123 name: String,
124 idempotency_key: Option<String>,
127 },
128}
129
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
132pub enum Format {
133 #[default]
134 Json,
135 Text,
136}
137
138const STANDARD_VERBS: [(&str, &str); 4] = [
139 ("validate", "lint"),
140 ("list", "builtins"),
141 ("create", "new"),
142 ("show", "trace"),
143];
144
145fn canonical(subcommand: &str) -> &str {
146 STANDARD_VERBS
147 .iter()
148 .find(|(alias, _)| *alias == subcommand)
149 .map_or(subcommand, |(_, name)| *name)
150}
151
152pub(crate) fn value<'a>(
155 it: &mut impl Iterator<Item = &'a String>,
156 name: &str,
157) -> Result<String, CliError> {
158 it.next()
159 .cloned()
160 .ok_or_else(|| CliError::Usage(format!("{name} needs a value\n{USAGE}")))
161}
162
163pub fn parse_args(argv: &[String]) -> Result<Command, CliError> {
165 if argv.iter().any(|a| a == "--version" || a == "-V") {
166 return Ok(Command::Version);
167 }
168 if argv.iter().any(|a| a == "--help") {
169 return Ok(Command::Help);
170 }
171
172 let mut it = argv.iter();
173 let subcommand = canonical(
174 it.next()
175 .ok_or_else(|| CliError::Usage(format!("no subcommand given\n{USAGE}")))?,
176 );
177 let rest = it.as_slice();
178 match subcommand {
179 "render" => render_args(rest),
180 "analyze" => analyze_args(rest),
181 "lint" => lint_args(rest),
182 "trace" => trace_args(rest),
183 "builtins" => builtins_args(rest),
184 "outline" => outline_args(rest),
185 "new" => new_args(rest),
186 other => Err(CliError::Usage(format!(
187 "unknown subcommand `{other}`\n{USAGE}"
188 ))),
189 }
190}
191
192fn builtins_args(rest: &[String]) -> Result<Command, CliError> {
193 match rest.first() {
194 None => Ok(Command::Builtins),
195 Some(extra) => Err(CliError::Usage(format!(
196 "builtins takes no arguments, not `{extra}`\n{USAGE}"
197 ))),
198 }
199}
200
201fn outline_args(rest: &[String]) -> Result<Command, CliError> {
202 match rest {
203 [text] => Ok(Command::Outline { text: text.clone() }),
204 _ => Err(CliError::Usage(format!(
205 "outline takes one <expression>, quoted as one argument\n{USAGE}"
206 ))),
207 }
208}
209
210fn new_args(rest: &[String]) -> Result<Command, CliError> {
211 match rest {
212 [name] if !name.starts_with("--") => Ok(Command::New {
213 name: name.clone(),
214 idempotency_key: None,
215 }),
216 [name, flag, key] if !name.starts_with("--") && flag == "--idempotency-key" => {
217 Ok(Command::New {
218 name: name.clone(),
219 idempotency_key: Some(key.clone()),
220 })
221 }
222 [] => Err(CliError::Usage(format!("missing <name>\n{USAGE}"))),
223 _ => Err(CliError::Usage(format!(
224 "new takes one <name> and an optional `--idempotency-key <key>`\n{USAGE}"
225 ))),
226 }
227}
228
229fn lint_args(rest: &[String]) -> Result<Command, CliError> {
230 let (dir, rest) = composition_flag(rest)?;
231 let mut it = rest.iter().peekable();
232 let target = match it.peek() {
233 Some(a) if !a.starts_with("--") => it.next().cloned(),
234 _ => None,
235 };
236 let mut format = Format::default();
237 while let Some(flag) = it.next() {
238 match flag.as_str() {
239 "--format" => {
240 format = match it.next().map(String::as_str) {
241 Some("json") => Format::Json,
242 Some("text") => Format::Text,
243 other => {
244 return Err(CliError::Usage(format!(
245 "`--format` takes json or text, not `{}`\n{USAGE}",
246 other.unwrap_or("nothing")
247 )));
248 }
249 };
250 }
251 extra => {
252 return Err(CliError::Usage(format!(
253 "lint takes only [<node|expression>], `--in <dir>` and `--format \
254 <json|text>`, not `{extra}`\n{USAGE}"
255 )));
256 }
257 }
258 }
259 Ok(Command::Lint {
260 target,
261 dir,
262 format,
263 })
264}
265
266pub(super) fn composition_flag(rest: &[String]) -> Result<(Option<String>, Vec<String>), CliError> {
269 let mut dir = None;
270 let mut kept = Vec::with_capacity(rest.len());
271 let mut it = rest.iter();
272 while let Some(arg) = it.next() {
273 match arg.as_str() {
274 "--in" => {
275 dir = Some(
276 it.next()
277 .cloned()
278 .ok_or_else(|| CliError::Usage(format!("`--in` needs a path\n{USAGE}")))?,
279 );
280 }
281 _ => kept.push(arg.clone()),
282 }
283 }
284 Ok((dir, kept))
285}
286
287fn trace_args(rest: &[String]) -> Result<Command, CliError> {
290 let (dir, rest) = composition_flag(rest)?;
291 match rest.as_slice() {
292 [target] if !target.starts_with("--") => Ok(Command::Trace {
293 target: target.clone(),
294 dir,
295 }),
296 [] => Err(CliError::Usage(format!(
297 "missing <node|expression>\n{USAGE}"
298 ))),
299 _ => Err(CliError::Usage(format!(
300 "trace takes one <node|expression>, `--in <dir>` and nothing else\n{USAGE}"
301 ))),
302 }
303}
304
305pub(crate) fn number(raw: &str, flag: &str) -> Result<f64, CliError> {
306 match raw.parse::<f64>() {
307 Ok(v) if v.is_finite() => Ok(v),
308 _ => Err(CliError::Usage(format!(
309 "{flag} needs a finite number, got `{raw}`\n{USAGE}"
310 ))),
311 }
312}
313
314pub(crate) fn positive(raw: &str, flag: &str) -> Result<f64, CliError> {
315 match number(raw, flag)? {
316 v if v > 0.0 => Ok(v),
317 _ => Err(CliError::Usage(format!(
318 "{flag} needs a positive number, got `{raw}`\n{USAGE}"
319 ))),
320 }
321}
322
323pub(crate) fn operations(raw: &str, flag: &str) -> Result<u128, CliError> {
324 raw.parse::<u128>().map_err(|_| not_a_count(raw, flag))
325}
326
327pub(crate) fn count(raw: &str, flag: &str) -> Result<usize, CliError> {
328 usize::try_from(operations(raw, flag)?).map_err(|_| not_a_count(raw, flag))
329}
330
331fn not_a_count(raw: &str, flag: &str) -> CliError {
332 CliError::Usage(format!("{flag} needs a whole count, got `{raw}`\n{USAGE}"))
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use std::path::Path;
339
340 fn argv(parts: &[&str]) -> Vec<String> {
341 parts.iter().map(|s| (*s).to_string()).collect()
342 }
343
344 fn rendered(parts: &[&str]) -> RenderArgs {
345 match parse_args(&argv(parts)) {
346 Ok(Command::Render(args)) => *args,
347 other => panic!("expected a render, got {other:?}"),
348 }
349 }
350
351 #[test]
352 fn version_and_help_are_recognized_anywhere_in_argv() {
353 assert_eq!(parse_args(&argv(&["--version"])).unwrap(), Command::Version);
354 assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Command::Version);
355 assert_eq!(
356 parse_args(&argv(&["lint", "--help"])).unwrap(),
357 Command::Help
358 );
359 }
360
361 #[test]
362 fn each_standard_verb_alias_reaches_the_subcommand_it_names() {
363 assert_eq!(
364 parse_args(&argv(&["validate"])).unwrap(),
365 Command::Lint {
366 target: None,
367 dir: None,
368 format: Format::Json,
369 }
370 );
371 assert_eq!(parse_args(&argv(&["list"])).unwrap(), Command::Builtins);
372 assert_eq!(
373 parse_args(&argv(&["show", "kick"])).unwrap(),
374 Command::Trace {
375 target: "kick".to_string(),
376 dir: None,
377 }
378 );
379 assert_eq!(
380 parse_args(&argv(&["create", "song1"])).unwrap(),
381 Command::New {
382 name: "song1".to_string(),
383 idempotency_key: None,
384 }
385 );
386 }
387
388 #[test]
391 fn the_sample_rate_is_an_observation_flag_render_takes_with_any_reading() {
392 for name in ["lines", "atoms", "samples", "ledger"] {
393 let args = rendered(&["render", "--as", name, "--sample-rate", "48000"]);
394 assert_eq!(args.sample_rate, Some(48_000));
395 assert_eq!(args.asked[0].name, name);
396 }
397 assert!(matches!(
398 parse_args(&argv(&["trace", "kick", "--sample-rate", "48000"])),
399 Err(CliError::Usage(_))
400 ));
401 }
402
403 #[test]
404 fn a_representation_that_left_the_language_names_what_replaced_it() {
405 for (gone, write) in sva_core::RETIRED {
406 let Err(CliError::Usage(message)) = parse_args(&argv(&["render", "--as", gone])) else {
407 panic!("`{gone}` must refuse")
408 };
409 assert!(message.contains(write), "{message}");
410 }
411 }
412
413 #[test]
414 fn a_wav_destination_takes_samples_and_nothing_else() {
415 let args = rendered(&["render", "--as", "samples=/tmp/out.wav", "--pcm16"]);
416 assert!(args.pcm16);
417 assert_eq!(
418 args.asked[0].dest.as_deref(),
419 Some(Path::new("/tmp/out.wav"))
420 );
421 assert!(matches!(
422 parse_args(&argv(&["render", "--as", "ledger=/tmp/out.wav"])),
423 Err(CliError::Usage(_))
424 ));
425 }
426
427 #[test]
428 fn render_needs_a_reading_and_bindings_needs_a_node() {
429 assert!(matches!(
430 parse_args(&argv(&["render"])),
431 Err(CliError::Usage(_))
432 ));
433 assert!(matches!(
434 parse_args(&argv(&["render", "--as", "bindings"])),
435 Err(CliError::Usage(_))
436 ));
437 let args = rendered(&["render", "--as", "bindings", "--node", "kick"]);
438 assert_eq!(args.node.as_deref(), Some("kick"));
439 }
440
441 #[test]
442 fn volatile_repeats_once_per_parameter_and_needs_a_name() {
443 let args = rendered(&["render", "--as", "samples", "--volatile", "cutoff"]);
444 assert_eq!(args.volatile, ["cutoff"]);
445 let args = rendered(&[
446 "render",
447 "--as",
448 "samples",
449 "--volatile",
450 "cutoff",
451 "--volatile",
452 "q",
453 ]);
454 assert_eq!(args.volatile, ["cutoff", "q"]);
455 assert!(rendered(&["render", "--as", "samples"]).volatile.is_empty());
456 assert!(matches!(
457 parse_args(&argv(&["render", "--as", "samples", "--volatile"])),
458 Err(CliError::Usage(_))
459 ));
460 }
461
462 #[test]
463 fn render_and_analyze_parse_the_same_window_flags() {
464 let args = rendered(&["render", "--as", "samples", "--from", "0.5", "--to", "2"]);
465 let window = (Some(WindowEdge::Secs(0.5)), Some(WindowEdge::Secs(2.0)));
466 assert_eq!((args.from, args.to), window);
467 let Ok(Command::Analyze(args)) = parse_args(&argv(&[
468 "analyze",
469 "/tmp/a.wav",
470 "--as",
471 "spectrum",
472 "--from",
473 "0.5",
474 "--to",
475 "2",
476 ])) else {
477 panic!("an analyze")
478 };
479 assert_eq!((args.from, args.to), window);
480 }
481
482 #[test]
483 fn to_silent_takes_bits_and_a_latest_time_and_nothing_else_does() {
484 let deep = rendered(&["render", "--as", "samples", "--to", "silent"]);
485 let bits = sva_core::DEFAULT_SILENT_BITS;
486 let max_secs = sva_core::DEFAULT_SILENT_MAX_SECS;
487 assert_eq!(deep.silent, Some(sva_core::Silent { bits, max_secs }));
488 assert_eq!(deep.to, None);
489 let timed = rendered(&["render", "--as", "samples", "--to", "silent", "--to", "3"]);
490 assert_eq!(
491 (timed.to, timed.silent),
492 (Some(WindowEdge::Secs(3.0)), None)
493 );
494 let later = rendered(&["render", "--as", "samples", "--to", "3", "--to", "silent"]);
495 assert_eq!((later.to, later.silent.map(|s| s.bits)), (None, Some(bits)));
496 let shallow = rendered(&[
497 "render",
498 "--as",
499 "samples",
500 "--to",
501 "silent:16",
502 "--max",
503 "8",
504 ]);
505 let max_secs = 8.0;
506 assert_eq!(
507 shallow.silent,
508 Some(sva_core::Silent { bits: 16, max_secs })
509 );
510 for refused in [
511 &["render", "--as", "samples", "--max", "8"][..],
512 &["render", "--as", "samples", "--to", "silent:0"],
513 &["render", "--as", "samples", "--to", "silent:54"],
514 &["render", "--as", "samples", "--to", "silently"],
515 &[
516 "analyze",
517 "/tmp/a.wav",
518 "--as",
519 "spectrum",
520 "--to",
521 "silent",
522 ],
523 ] {
524 assert!(
525 matches!(parse_args(&argv(refused)), Err(CliError::Usage(_))),
526 "{refused:?}"
527 );
528 }
529 }
530
531 #[test]
532 fn analyze_takes_a_wav_and_only_the_readings_a_buffer_answers() {
533 assert!(matches!(
534 parse_args(&argv(&["analyze", "/tmp/a.mp3", "--as", "spectrum"])),
535 Err(CliError::Usage(_))
536 ));
537 assert!(matches!(
538 parse_args(&argv(&["analyze", "/tmp/a.wav", "--as", "ledger"])),
539 Err(CliError::Usage(_))
540 ));
541 assert!(matches!(
542 parse_args(&argv(&["analyze", "/tmp/a.wav"])),
543 Err(CliError::Usage(_))
544 ));
545 }
546
547 #[test]
548 fn lint_takes_an_optional_target_and_nothing_else() {
549 assert_eq!(
550 parse_args(&argv(&["lint"])).unwrap(),
551 Command::Lint {
552 target: None,
553 dir: None,
554 format: Format::Json
555 }
556 );
557 assert_eq!(
558 parse_args(&argv(&["lint", "drums/kick"])).unwrap(),
559 Command::Lint {
560 target: Some("drums/kick".to_string()),
561 dir: None,
562 format: Format::Json
563 }
564 );
565 assert!(matches!(
566 parse_args(&argv(&["lint", "a", "b"])),
567 Err(CliError::Usage(_))
568 ));
569 }
570
571 #[test]
572 fn new_takes_a_name_and_an_optional_idempotency_key() {
573 assert_eq!(
574 parse_args(&argv(&["new", "song1", "--idempotency-key", "k"])).unwrap(),
575 Command::New {
576 name: "song1".to_string(),
577 idempotency_key: Some("k".to_string()),
578 }
579 );
580 assert!(matches!(
581 parse_args(&argv(&["new"])),
582 Err(CliError::Usage(_))
583 ));
584 }
585
586 #[test]
587 fn builtins_takes_no_arguments() {
588 assert_eq!(parse_args(&argv(&["builtins"])).unwrap(), Command::Builtins);
589 assert!(matches!(
590 parse_args(&argv(&["builtins", "x"])),
591 Err(CliError::Usage(_))
592 ));
593 }
594
595 #[test]
596 fn a_subcommand_this_cli_does_not_answer_refuses_by_name() {
597 for gone in ["bench", "nonlinear-solve"] {
598 let Err(CliError::Usage(message)) = parse_args(&argv(&[gone])) else {
599 panic!("`{gone}` must refuse")
600 };
601 assert!(message.contains(gone), "{message}");
602 }
603 }
604}