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