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 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
45/// Only the readings that are a pure function of a buffer; the rest need the graph behind it.
46pub 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    /// A node path or an expression; which one it is, only the graph can say.
62    pub target: Option<String>,
63    /// The instance a reading is taken of, where it is not the target itself.
64    pub node: Option<String>,
65    /// The composition directory `--in` named, where the caller named one.
66    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    /// The caller said a destination that already holds a file may be replaced.
76    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    /// The readings `sva-analysis` answers, which no `Representation` names.
87    pub analyses: Vec<(String, Option<PathBuf>)>,
88    pub against: Option<PathBuf>,
89    /// The caller said a destination that already holds a file may be replaced.
90    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    /// A node path or an expression, same grammar as render's target; `None` lints the whole
100    /// directory.
101    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        /// Present where the caller says this is a retry, per the `cli` standard's own
114        /// requirement that a create verb be made idempotent.
115        idempotency_key: Option<String>,
116    },
117}
118
119/// How the diagnostics an evaluator answers are rendered. One set of objects, two renderings.
120#[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
141/// A free function rather than a closure per parser: a closure would hold its loop's
142/// iterator borrowed.
143pub(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
152/// `--version`/`-V` and `--help` are global flags, recognized anywhere in argv (cli standard).
153pub 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
245/// `--in <dir>` names the composition, wherever in the tail it is written; without it, the
246/// process's own directory is the one a subcommand reads.
247pub(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
266/// One positional and nothing else: a trace answers structure, which no option narrows —
267/// the rate is an observation parameter and a trace takes no observation.
268fn 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    /// The rate is an observation parameter, so it is legal beside any `--as` and nowhere
368    /// near a structural subcommand.
369    #[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}