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