Skip to main content

sva_cli/args/
mod.rs

1// Concern: parses argv into the subcommands this CLI answers and their options | Non-concern: what a target names (sva-core) | IO: (argv) -> Command or CliError
2
3use 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
49/// Only the readings that are a pure function of a buffer; the rest need the graph behind it.
50pub 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    /// A node path or an expression; which one it is, only the graph can say.
66    pub target: Option<String>,
67    /// The instance a reading is taken of, where it is not the target itself.
68    pub node: Option<String>,
69    /// The composition directory `--in` named, where the caller named one.
70    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    /// `--to silent`: the end is where silence is proven, not a time.
76    pub silent: Option<sva_core::Silent>,
77    pub asked: Vec<Asked>,
78    pub brief: bool,
79    pub skim: bool,
80    pub pcm16: bool,
81    /// The caller said a destination that already holds a file may be replaced.
82    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    /// The readings `sva-analysis` answers, which no `Representation` names.
94    pub analyses: Vec<(String, Option<PathBuf>)>,
95    pub against: Option<PathBuf>,
96    /// The caller said a destination that already holds a file may be replaced.
97    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    /// A node path or an expression, same grammar as render's target; `None` lints the whole
107    /// directory.
108    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    /// An expression's own parse tree; it reads no composition.
119    Outline {
120        text: String,
121    },
122    New {
123        name: String,
124        /// Present where the caller says this is a retry, per the `cli` standard's own
125        /// requirement that a create verb be made idempotent.
126        idempotency_key: Option<String>,
127    },
128}
129
130/// How the diagnostics an evaluator answers are rendered. One set of objects, two renderings.
131#[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
152/// A free function rather than a closure per parser: a closure would hold its loop's
153/// iterator borrowed.
154pub(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
163/// `--version`/`-V` and `--help` are global flags, recognized anywhere in argv (cli standard).
164pub 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
266/// `--in <dir>` names the composition, wherever in the tail it is written; without it, the
267/// process's own directory is the one a subcommand reads.
268pub(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
287/// One positional and nothing else: a trace answers structure, which no option narrows —
288/// the rate is an observation parameter and a trace takes no observation.
289fn 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    /// The rate is an observation parameter, so it is legal beside any `--as` and nowhere
389    /// near a structural subcommand.
390    #[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}