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