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