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