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