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};
6
7mod reading;
8
9pub(crate) use reading::check_frame;
10use reading::{analyze_args, render_args};
11
12#[derive(Debug, PartialEq)]
13pub struct RenderArgs {
14    /// One expression; its refs name nodes from the current directory or an absolute path.
15    pub target: String,
16    pub until: Option<String>,
17    pub rate: Option<u32>,
18    /// The precision every sample is written to; the profile's own where `None`.
19    pub bits: Option<i32>,
20    /// The most threads it computes on; every core where `None`.
21    pub threads: Option<std::num::NonZeroUsize>,
22    pub asked: Vec<Asked>,
23    /// The caller said a destination that already holds a file may be replaced.
24    pub confirm: bool,
25    pub cache: CacheAt,
26}
27
28/// Where a render's persistent store lives.
29#[derive(Clone, Debug, Default, PartialEq, Eq)]
30pub enum CacheAt {
31    #[default]
32    Platform,
33    Path(PathBuf),
34    Off,
35}
36
37#[derive(Debug, PartialEq)]
38pub struct AnalyzeArgs {
39    pub path: PathBuf,
40    pub asked: Vec<Asked>,
41    /// The caller said a destination that already holds a file may be replaced.
42    pub confirm: bool,
43}
44
45#[derive(Debug, PartialEq)]
46pub enum Command {
47    Version,
48    Help,
49    Render(Box<RenderArgs>),
50    Analyze(Box<AnalyzeArgs>),
51    /// One expression, as render's target; `None` lints every file under its own rules.
52    Lint {
53        target: Option<String>,
54        format: Format,
55    },
56    Trace {
57        target: String,
58    },
59    Builtins,
60    /// An expression's own parse tree; it reads no composition.
61    Outline {
62        text: String,
63    },
64    New {
65        name: String,
66        /// Present where the caller says this is a retry, per the `cli` standard's own
67        /// requirement that a create verb be made idempotent.
68        idempotency_key: Option<String>,
69    },
70}
71
72/// How the diagnostics an evaluator answers are rendered. One set of objects, two renderings.
73#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
74pub enum Format {
75    #[default]
76    Json,
77    Text,
78}
79
80const STANDARD_VERBS: [(&str, &str); 4] = [
81    ("validate", "lint"),
82    ("list", "builtins"),
83    ("create", "new"),
84    ("show", "trace"),
85];
86
87fn canonical(subcommand: &str) -> &str {
88    STANDARD_VERBS
89        .iter()
90        .find(|(alias, _)| *alias == subcommand)
91        .map_or(subcommand, |(_, name)| *name)
92}
93
94/// A free function rather than a closure per parser: a closure would hold its loop's
95/// iterator borrowed.
96pub(crate) fn value<'a>(
97    it: &mut impl Iterator<Item = &'a String>,
98    name: &str,
99) -> Result<String, CliError> {
100    it.next()
101        .cloned()
102        .ok_or_else(|| CliError::usage("cli.missing_value", format!("{name} needs a value")))
103}
104
105/// `--version`/`-V` and `--help` are global flags, recognized anywhere in argv (cli standard).
106pub fn parse_args(argv: &[String]) -> Result<Command, CliError> {
107    if argv.iter().any(|a| a == "--version" || a == "-V") {
108        return Ok(Command::Version);
109    }
110    if argv.iter().any(|a| a == "--help") {
111        return Ok(Command::Help);
112    }
113
114    let mut it = argv.iter();
115    let subcommand = canonical(
116        it.next()
117            .ok_or_else(|| CliError::usage("cli.missing_subcommand", "no subcommand given"))?,
118    );
119    let rest = it.as_slice();
120    match subcommand {
121        "render" => render_args(rest),
122        "analyze" => analyze_args(rest),
123        "lint" => lint_args(rest),
124        "trace" => trace_args(rest),
125        "builtins" => builtins_args(rest),
126        "outline" => outline_args(rest),
127        "new" => new_args(rest),
128        other => Err(CliError::usage(
129            "cli.unknown_subcommand",
130            format!("unknown subcommand `{other}`"),
131        )),
132    }
133}
134
135fn builtins_args(rest: &[String]) -> Result<Command, CliError> {
136    match rest.first() {
137        None => Ok(Command::Builtins),
138        Some(extra) => Err(CliError::usage(
139            "cli.unknown_argument",
140            format!("builtins takes no arguments, not `{extra}`"),
141        )),
142    }
143}
144
145fn outline_args(rest: &[String]) -> Result<Command, CliError> {
146    match rest {
147        [text] => Ok(Command::Outline { text: text.clone() }),
148        _ => Err(CliError::usage(
149            "cli.missing_argument",
150            "outline takes one <expression>, quoted as one argument",
151        )),
152    }
153}
154
155fn new_args(rest: &[String]) -> Result<Command, CliError> {
156    match rest {
157        [name] if !name.starts_with("--") => Ok(Command::New {
158            name: name.clone(),
159            idempotency_key: None,
160        }),
161        [name, flag, key] if !name.starts_with("--") && flag == "--idempotency-key" => {
162            Ok(Command::New {
163                name: name.clone(),
164                idempotency_key: Some(key.clone()),
165            })
166        }
167        [] => Err(CliError::usage("cli.missing_argument", "missing <name>")),
168        _ => Err(CliError::usage(
169            "cli.unknown_argument",
170            "new takes one <name> and an optional `--idempotency-key <key>`",
171        )),
172    }
173}
174
175fn lint_args(rest: &[String]) -> Result<Command, CliError> {
176    let mut it = rest.iter().peekable();
177    let target = match it.peek() {
178        Some(a) if !a.starts_with("--") => it.next().cloned(),
179        _ => None,
180    };
181    let mut format = Format::default();
182    while let Some(flag) = it.next() {
183        match flag.as_str() {
184            "--format" => {
185                format = match it.next().map(String::as_str) {
186                    Some("json") => Format::Json,
187                    Some("text") => Format::Text,
188                    other => {
189                        return Err(CliError::usage(
190                            "cli.bad_value",
191                            format!(
192                                "`--format` takes json or text, not `{}`",
193                                other.unwrap_or("nothing")
194                            ),
195                        ));
196                    }
197                };
198            }
199            extra => {
200                return Err(CliError::usage(
201                    "cli.unknown_argument",
202                    format!(
203                        "lint takes only ['<expression>'] and `--format <json|text>`, not \
204                     `{extra}`"
205                    ),
206                ));
207            }
208        }
209    }
210    Ok(Command::Lint { target, format })
211}
212
213/// One positional and nothing else: a trace answers structure, which no option narrows.
214fn trace_args(rest: &[String]) -> Result<Command, CliError> {
215    match rest {
216        [target] if !target.starts_with("--") => Ok(Command::Trace {
217            target: target.clone(),
218        }),
219        [] => Err(CliError::usage(
220            "cli.missing_argument",
221            "missing <node|expression>",
222        )),
223        _ => Err(CliError::usage(
224            "cli.unknown_argument",
225            "trace takes one <node|expression> and nothing else",
226        )),
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use std::path::Path;
234
235    fn argv(parts: &[&str]) -> Vec<String> {
236        parts.iter().map(|s| (*s).to_string()).collect()
237    }
238
239    fn rendered(parts: &[&str]) -> RenderArgs {
240        match parse_args(&argv(parts)) {
241            Ok(Command::Render(args)) => *args,
242            other => panic!("expected a render, got {other:?}"),
243        }
244    }
245
246    fn refused(parts: &[&str]) -> String {
247        match parse_args(&argv(parts)) {
248            Err(CliError::Usage { message, .. }) => message,
249            other => panic!("expected a usage refusal, got {other:?}"),
250        }
251    }
252
253    /// Each mistake answers a code a caller branches on, its message quoting the text it
254    /// refuses, and no banner: `--help` is where the whole surface is stated.
255    #[test]
256    fn each_argument_mistake_answers_its_own_code_naming_the_text() {
257        let render = |tail: &[&'static str]| [&["render", "@a"][..], tail].concat();
258        let cases: [(Vec<&str>, &str, &str); 15] = [
259            (
260                render(&["--representation", "spectre"]),
261                "query.unknown_representation",
262                "`spectre`",
263            ),
264            (
265                render(&["--representation", "lines(depth=2)"]),
266                "query.unknown_argument",
267                "`depth`",
268            ),
269            (
270                render(&["--representation", "ledger(depth=many)"]),
271                "query.bad_value",
272                "`many`",
273            ),
274            (
275                render(&["--representation", "lines,lines"]),
276                "query.duplicate",
277                "`lines`",
278            ),
279            (
280                render(&["--representation", "bindings"]),
281                "query.missing_argument",
282                "`bindings`",
283            ),
284            (
285                render(&["--representation", "spectrum(peaks=8"]),
286                "query.malformed",
287                "`spectrum(peaks=8`",
288            ),
289            (
290                render(&["--representation", "exact-envelope"]),
291                "query.retired",
292                "`exact-envelope`",
293            ),
294            (
295                render(&["--representation", "a=x,b=x"]),
296                "query.unknown_representation",
297                "`a`",
298            ),
299            (
300                render(&["--representation", "lines=x.json,atoms=./x.json"]),
301                "query.shared_destination",
302                "x.json",
303            ),
304            (
305                render(&["--representation", "lines", "--bogus"]),
306                "cli.unknown_argument",
307                "`--bogus`",
308            ),
309            (
310                render(&["--representation"]),
311                "cli.missing_value",
312                "--representation",
313            ),
314            (
315                render(&["--representation", "lines", "--rate", "0"]),
316                "cli.bad_value",
317                "`0`",
318            ),
319            (vec!["bench"], "cli.unknown_subcommand", "`bench`"),
320            (
321                vec!["analyze", "/tmp/a.wav", "--representation", "ledger"],
322                "query.not_analyzable",
323                "`ledger`",
324            ),
325            (
326                vec!["analyze", "/tmp/a.flac", "--representation", "bands"],
327                "analyze.not_wav",
328                "`/tmp/a.flac`",
329            ),
330        ];
331        for (parts, code, text) in cases {
332            let Err(CliError::Usage {
333                code: held,
334                message,
335            }) = parse_args(&argv(&parts))
336            else {
337                panic!("{parts:?} refuses as an argument mistake");
338            };
339            assert_eq!(held, code, "{parts:?}: {message}");
340            assert!(message.contains(text), "{parts:?}: {message}");
341            assert!(!message.contains("usage:"), "{parts:?}: {message}");
342        }
343    }
344
345    #[test]
346    fn version_and_help_are_recognized_anywhere_in_argv() {
347        assert_eq!(parse_args(&argv(&["--version"])).unwrap(), Command::Version);
348        assert_eq!(parse_args(&argv(&["-V"])).unwrap(), Command::Version);
349        assert_eq!(
350            parse_args(&argv(&["lint", "--help"])).unwrap(),
351            Command::Help
352        );
353    }
354
355    #[test]
356    fn each_standard_verb_alias_reaches_the_subcommand_it_names() {
357        assert_eq!(
358            parse_args(&argv(&["validate"])).unwrap(),
359            Command::Lint {
360                target: None,
361                format: Format::Json,
362            }
363        );
364        assert_eq!(parse_args(&argv(&["list"])).unwrap(), Command::Builtins);
365        assert_eq!(
366            parse_args(&argv(&["show", "kick"])).unwrap(),
367            Command::Trace {
368                target: "kick".to_string(),
369            }
370        );
371        assert_eq!(
372            parse_args(&argv(&["create", "song1"])).unwrap(),
373            Command::New {
374                name: "song1".to_string(),
375                idempotency_key: None,
376            }
377        );
378    }
379
380    /// A render names its target, its readings as calls in one comma list or several, each
381    /// reading's options as its own arguments, and its flags; nothing else.
382    #[test]
383    fn a_render_takes_a_target_readings_with_their_arguments_and_flags() {
384        let args = rendered(&[
385            "render",
386            "@piano([0, 2b], f0=C4)",
387            "--representation",
388            "samples=/tmp/out.wav, spectrum(peaks=8, frame=50ms)",
389            "--representation",
390            "ledger(depth=2, brief=1)=/tmp/ledger.json,bindings(node=@voice)",
391            "--rate",
392            "48000",
393            "--bits",
394            "16",
395            "--until",
396            "t > 1s",
397            "--threads",
398            "3",
399        ]);
400        assert_eq!(args.target, "@piano([0, 2b], f0=C4)");
401        assert_eq!(args.until.as_deref(), Some("t > 1s"));
402        assert_eq!(args.rate, Some(48_000));
403        assert_eq!(args.bits, Some(16));
404        assert_eq!(args.threads.map(std::num::NonZeroUsize::get), Some(3));
405        let names: Vec<&str> = args.asked.iter().map(|a| a.name.as_str()).collect();
406        assert_eq!(names, ["samples", "spectrum", "ledger", "bindings"]);
407        assert_eq!(args.asked[0].path(), Some(Path::new("/tmp/out.wav")));
408        assert_eq!(
409            args.asked[1].representation,
410            sva_engine::Representation::Spectrum {
411                max_peaks: 8,
412                frame_secs: Some(0.05)
413            }
414        );
415        assert_eq!(
416            args.asked[2].representation,
417            sva_engine::Representation::Ledger { depth: 2 }
418        );
419        assert!(args.asked[2].brief);
420        assert_eq!(args.asked[2].path(), Some(Path::new("/tmp/ledger.json")));
421        assert_eq!(args.asked[3].node.as_deref(), Some("voice"));
422    }
423
424    #[test]
425    fn a_render_on_no_threads_is_refused() {
426        let message = refused(&[
427            "render",
428            "1",
429            "--representation",
430            "envelope",
431            "--threads",
432            "0",
433        ]);
434        assert!(message.contains("--threads"), "{message}");
435    }
436
437    #[test]
438    fn a_target_may_open_with_a_minus() {
439        let args = rendered(&["render", "-1*sin(2*pi*220*t)", "--representation", "lines"]);
440        assert_eq!(args.target, "-1*sin(2*pi*220*t)");
441    }
442
443    #[test]
444    fn a_render_with_no_target_or_no_reading_refuses() {
445        refused(&["render"]);
446        refused(&["render", "--representation", "lines"]);
447        refused(&["render", "@a"]);
448    }
449
450    /// Every flag the release before this one read is gone, with no alias left behind.
451    #[test]
452    fn a_retired_flag_refuses_by_name() {
453        for gone in [
454            "-c",
455            "--in",
456            "--from",
457            "--to",
458            "--max",
459            "--sample-rate",
460            "--as",
461            "--node",
462            "--frame",
463            "--depth",
464            "--peaks",
465            "--oversample",
466            "--brief",
467            "--skim",
468            "--pcm16",
469        ] {
470            let message = refused(&["render", "@a", "--representation", "lines", gone, "x"]);
471            assert!(message.contains(gone), "{gone}: {message}");
472        }
473    }
474
475    #[test]
476    fn an_argument_its_reading_does_not_take_or_a_malformed_one_refuses() {
477        for reading in [
478            "lines(depth=2)",
479            "ledger(colour=red)",
480            "ledger(depth)",
481            "ledger(depth=many)",
482            "ledger(brief=yes)",
483            "samples(bits=16)",
484            "spectrum(peaks=8",
485            "bindings(node=voice)",
486        ] {
487            refused(&["render", "@a", "--representation", reading]);
488        }
489    }
490
491    /// The answer keys a reading by its name, so asking one twice would print that key twice.
492    #[test]
493    fn a_reading_asked_twice_refuses_by_name() {
494        for written in [
495            &["--representation", "spectrum(peaks=4),spectrum(frame=20ms)"][..],
496            &["--representation", "lines", "--representation", "lines"][..],
497            &["--representation", "samples=/tmp/a.wav,samples"][..],
498        ] {
499            let message = refused(&[&["render", "@a"][..], written].concat());
500            assert!(message.contains("asked more than once"), "{message}");
501        }
502        let message = refused(&[
503            "analyze",
504            "/tmp/a.wav",
505            "--representation",
506            "pitch,pitch(peaks=2)",
507        ]);
508        assert!(message.contains("`pitch`"), "{message}");
509    }
510
511    #[test]
512    fn a_representation_that_left_the_language_names_what_replaced_it() {
513        for (gone, write) in sva_core::RETIRED {
514            let message = refused(&["render", "@a", "--representation", gone]);
515            assert!(message.contains(write), "{message}");
516        }
517    }
518
519    #[test]
520    fn a_wav_destination_takes_samples_and_nothing_else() {
521        refused(&["render", "@a", "--representation", "ledger=/tmp/out.wav"]);
522    }
523
524    /// `alsa:` after `=` names a live device; its buffer is the reading's own argument, at
525    /// the table's default until written, and the other flags and readings stand beside it.
526    #[test]
527    fn a_device_destination_takes_samples_and_a_buffer_beside_every_other_flag() {
528        use sva_core::Destination;
529        let row = sva_engine::Representation::Samples.reading();
530        assert_eq!(row.takes, [sva_engine::Arg::Buffer]);
531        assert_eq!(sva_engine::Arg::Buffer.key(), "buffer");
532        let args = rendered(&["render", "@a", "--representation", "samples=alsa:default"]);
533        assert_eq!(
534            args.asked[0].dest,
535            Some(Destination::Alsa {
536                device: "default".to_string(),
537                buffer_secs: sva_engine::DEFAULT_BUFFER_SECS,
538            })
539        );
540        assert_eq!(args.asked[0].path(), None);
541        let args = rendered(&[
542            "render",
543            "@a",
544            "--representation",
545            "samples(buffer=20ms)=alsa:hw:1,0,loudness",
546            "--until",
547            "t > 1s",
548            "--rate",
549            "44100",
550        ]);
551        let names: Vec<&str> = args.asked.iter().map(|a| a.name.as_str()).collect();
552        assert_eq!(names, ["samples", "loudness"]);
553        assert_eq!(
554            args.asked[0].dest,
555            Some(Destination::Alsa {
556                device: "hw:1,0".to_string(),
557                buffer_secs: 0.02,
558            }),
559            "a comma inside a device name stays in it where no reading follows"
560        );
561        assert_eq!(args.asked[1].dest, None);
562        assert_eq!(args.until.as_deref(), Some("t > 1s"));
563        assert_eq!(args.rate, Some(44_100));
564        let args = rendered(&[
565            "render",
566            "@a",
567            "--representation",
568            "lines,samples=alsa:plughw:CARD=PCH,DEV=0",
569        ]);
570        assert_eq!(
571            args.asked[1].dest,
572            Some(Destination::Alsa {
573                device: "plughw:CARD=PCH,DEV=0".to_string(),
574                buffer_secs: sva_engine::DEFAULT_BUFFER_SECS,
575            })
576        );
577    }
578
579    #[test]
580    fn a_device_refuses_what_it_cannot_play_naming_the_text() {
581        let render = |r: &'static str| vec!["render", "@a", "--representation", r];
582        let cases: [(Vec<&str>, &str, &str); 9] = [
583            (
584                render("samples=alsa:hw:0,samples=alsa:hw:1"),
585                "query.duplicate",
586                "`samples`",
587            ),
588            (
589                render("samples=alsa:"),
590                "destination.no_device",
591                "`samples=alsa:`",
592            ),
593            (
594                render("loudness=alsa:default"),
595                "destination.not_audio",
596                "`loudness`",
597            ),
598            (
599                render("samples(buffer=50ms)"),
600                "query.buffer_without_device",
601                "`samples`",
602            ),
603            (
604                render("samples(buffer=50ms)=/tmp/a.wav"),
605                "query.buffer_without_device",
606                "`buffer`",
607            ),
608            (
609                render("samples(buffer=0)=alsa:default"),
610                "query.bad_value",
611                "`0`",
612            ),
613            (
614                render("samples(buffer=5000s)=alsa:default"),
615                "query.bad_value",
616                "`5000s`",
617            ),
618            (
619                render("lines(buffer=1)"),
620                "query.unknown_argument",
621                "`buffer`",
622            ),
623            (
624                vec![
625                    "analyze",
626                    "/tmp/a.wav",
627                    "--representation",
628                    "samples=alsa:hw:1",
629                ],
630                "destination.needs_render",
631                "`alsa:hw:1`",
632            ),
633        ];
634        for (parts, code, text) in cases {
635            let Err(CliError::Usage {
636                code: held,
637                message,
638            }) = parse_args(&argv(&parts))
639            else {
640                panic!("{parts:?} refuses as an argument mistake");
641            };
642            assert_eq!(held, code, "{parts:?}: {message}");
643            assert!(message.contains(text), "{parts:?}: {message}");
644        }
645    }
646
647    #[cfg(not(target_os = "linux"))]
648    #[test]
649    fn off_linux_a_device_refuses_at_parse_as_an_unsupported_platform() {
650        let parts = argv(&["render", "@a", "--representation", "samples=alsa:default"]);
651        let Err(CliError::Usage { code, .. }) = parse_args(&parts) else {
652            panic!("refused");
653        };
654        assert_eq!(code, "destination.unsupported_platform");
655    }
656
657    #[test]
658    fn a_harmonic_reading_takes_its_floor_in_decibels_and_its_norm_by_name() {
659        let args = rendered(&[
660            "render",
661            "@a",
662            "--representation",
663            "voice_leading(frame=10ms, floor=-60db, norm=l2),dissonance",
664        ]);
665        assert_eq!(
666            args.asked[0].representation,
667            sva_engine::Representation::Harmony {
668                reading: sva_engine::Harmonic::VoiceLeading {
669                    norm: sva_engine::harmony::voice_leading::Norm::L2
670                },
671                frame_secs: 0.01,
672                floor_db: -60.0,
673            }
674        );
675        assert_eq!(
676            args.asked[1].representation,
677            sva_engine::Representation::Harmony {
678                reading: sva_engine::Harmonic::Dissonance {
679                    max_pairs: sva_engine::DEFAULT_MAX_PAIRS
680                },
681                frame_secs: sva_engine::DEFAULT_FRAME_SECS,
682                floor_db: sva_engine::DEFAULT_FLOOR_DB,
683            }
684        );
685        let render = |r: &'static str| vec!["render", "@a", "--representation", r];
686        for (parts, text) in [
687            (render("dissonance(floor=-60)"), "`-60`"),
688            (render("voice_leading(norm=l3)"), "`l3`"),
689            (render("dissonance(norm=l1)"), "`norm`"),
690        ] {
691            let Err(CliError::Usage { message, .. }) = parse_args(&argv(&parts)) else {
692                panic!("{parts:?} refuses as an argument mistake");
693            };
694            assert!(message.contains(text), "{parts:?}: {message}");
695        }
696    }
697
698    #[test]
699    fn inharmonicity_takes_the_harmonic_frame_and_floor() {
700        let args = rendered(&[
701            "render",
702            "@a",
703            "--representation",
704            "inharmonicity(frame=10ms, floor=-60db)",
705        ]);
706        assert_eq!(
707            args.asked[0].representation,
708            sva_engine::Representation::Harmony {
709                reading: sva_engine::Harmonic::Inharmonicity,
710                frame_secs: 0.01,
711                floor_db: -60.0,
712            }
713        );
714    }
715
716    #[test]
717    fn periodicity_takes_its_step_window_and_curve() {
718        let args = rendered(&[
719            "render",
720            "@a",
721            "--representation",
722            "periodicity(frame=0.5s, window=8s, curve=1)",
723        ]);
724        assert_eq!(
725            args.asked[0].representation,
726            sva_engine::Representation::Periodicity {
727                frame_secs: 0.5,
728                window_secs: 8.0,
729                curve: true,
730            }
731        );
732    }
733
734    #[test]
735    fn modulation_takes_its_step_window_and_bpm() {
736        let args = rendered(&[
737            "render",
738            "@a",
739            "--representation",
740            "modulation(frame=0.25s, window=6s, bpm=140)",
741        ]);
742        assert_eq!(
743            args.asked[0].representation,
744            sva_engine::Representation::Modulation {
745                frame_secs: 0.25,
746                window_secs: 6.0,
747                bpm: Some(140.0),
748            }
749        );
750        refused(&["render", "@a", "--representation", "modulation(bpm=0)"]);
751        refused(&["render", "@a", "--representation", "modulation(bpm=2s)"]);
752    }
753
754    #[test]
755    fn self_similarity_takes_its_cell_smooth_window_and_listing() {
756        let args = rendered(&[
757            "render",
758            "@a",
759            "--representation",
760            "self_similarity(frame=0.1s, cell=1s, smooth=3s, window=12s, floor=-50db, \
761             peaks=4, curve=1)",
762        ]);
763        assert_eq!(
764            args.asked[0].representation,
765            sva_engine::Representation::SelfSimilarity {
766                frame_secs: 0.1,
767                cell_secs: 1.0,
768                smooth_secs: 3.0,
769                window_secs: 12.0,
770                floor_db: -50.0,
771                listed: 4,
772                curve: true,
773            }
774        );
775        refused(&[
776            "render",
777            "@a",
778            "--representation",
779            "self_similarity(cell=0)",
780        ]);
781        refused(&[
782            "render",
783            "@a",
784            "--representation",
785            "self_similarity(smooth=x)",
786        ]);
787    }
788
789    #[test]
790    fn bindings_needs_a_node() {
791        refused(&["render", "@a", "--representation", "bindings"]);
792    }
793
794    #[test]
795    fn lint_takes_an_optional_target_and_nothing_else() {
796        assert_eq!(
797            parse_args(&argv(&["lint", "@drums/kick"])).unwrap(),
798            Command::Lint {
799                target: Some("@drums/kick".to_string()),
800                format: Format::Json,
801            }
802        );
803        refused(&["lint", "a", "b"]);
804        refused(&["lint", "--in", "x"]);
805    }
806
807    #[test]
808    fn new_takes_a_name_and_an_optional_idempotency_key() {
809        assert_eq!(
810            parse_args(&argv(&["new", "song1", "--idempotency-key", "k"])).unwrap(),
811            Command::New {
812                name: "song1".to_string(),
813                idempotency_key: Some("k".to_string()),
814            }
815        );
816        refused(&["new"]);
817    }
818
819    #[test]
820    fn builtins_takes_no_arguments() {
821        assert_eq!(parse_args(&argv(&["builtins"])).unwrap(), Command::Builtins);
822        refused(&["builtins", "x"]);
823    }
824
825    #[test]
826    fn a_subcommand_this_cli_does_not_answer_refuses_by_name() {
827        for gone in ["bench", "nonlinear-solve"] {
828            assert!(refused(&[gone]).contains(gone));
829        }
830    }
831}