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