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