1use std::path::{Path, PathBuf};
4
5use sva_ast::SpanUnit;
6use sva_ast::score::{Field, Kind, Reading as Folding, Refused, evaluate};
7use sva_engine::harmony::voice_leading::Norm;
8use sva_engine::{
9 Arg, DEFAULT_BUFFER_SECS, DEFAULT_FRAME_SECS, Given, Harmonic, MAX_PINNED_FRAME, READINGS,
10 Reading, Representation, pinned_frame,
11};
12
13use crate::cli_error::CliError;
14
15pub const RETIRED: [(&str, &str); 2] = [
16 ("exact-envelope", "envelope, symbolic on a closed form"),
17 ("exact-derivative", "derivative, symbolic on a closed form"),
18];
19
20#[derive(Clone, Debug, PartialEq)]
23pub struct Call {
24 pub name: String,
25 pub args: Vec<(String, String)>,
26 pub dest: Option<PathBuf>,
28}
29
30#[derive(Clone, Debug, PartialEq)]
33pub enum Destination {
34 File(PathBuf),
35 Alsa {
36 device: String,
38 buffer_secs: f64,
40 },
41}
42
43const MOST_SAMPLES: f64 = 9_007_199_254_740_992.0;
46
47#[derive(Clone, Debug, PartialEq)]
50pub struct Asked {
51 pub name: String,
52 given: Given,
53 times: Vec<(Arg, Time)>,
54 pub dest: Option<Destination>,
55 pub node: Option<String>,
57 pub brief: bool,
59 pub skim: bool,
61}
62
63pub fn is_wav(path: &Path) -> bool {
64 path.extension()
65 .is_some_and(|e| e.eq_ignore_ascii_case("wav"))
66}
67
68pub fn retired(name: &str) -> Option<&'static str> {
69 RETIRED
70 .iter()
71 .find(|(gone, _)| *gone == name)
72 .map(|(_, write)| *write)
73}
74
75pub fn calls(text: &str) -> Result<Vec<Call>, CliError> {
78 let mut joined: Vec<String> = Vec::new();
79 for one in split_outside(text, ',')? {
80 match joined.last_mut() {
81 Some(last) if names_a_device(last) && !names_a_reading(one) => {
82 last.push(',');
83 last.push_str(one);
84 }
85 _ => joined.push(one.to_string()),
86 }
87 }
88 joined.iter().map(|one| call(one.trim())).collect()
89}
90
91fn names_a_device(call: &str) -> bool {
92 let close = call.rfind(')').map_or(0, |at| at + 1);
93 call[close..]
94 .split_once('=')
95 .is_some_and(|(_, dest)| dest.trim_start().starts_with("alsa:"))
96}
97
98fn names_a_reading(text: &str) -> bool {
99 let head = text[..text.find(['(', '=']).unwrap_or(text.len())].trim();
100 READINGS.iter().any(|row| row.name == head) || retired(head).is_some()
101}
102
103pub fn call(text: &str) -> Result<Call, CliError> {
105 let head_end = text.find(['(', '=']).unwrap_or(text.len());
106 let name = text[..head_end].trim().to_string();
107 if name.is_empty() {
108 return Err(unwritten(text, "it names no reading"));
109 }
110 let mut rest = &text[head_end..];
111 let mut args = Vec::new();
112 if rest.starts_with('(') {
113 let close = closing(rest).ok_or_else(|| unwritten(text, "its `(` is never closed"))?;
114 let inner = rest[1..close].trim();
115 if !inner.is_empty() {
116 for arg in split_outside(inner, ',')? {
117 let Some((key, value)) = arg.split_once('=') else {
118 return Err(unwritten(
119 text,
120 &format!("`{}` is no named argument; write `key=value`", arg.trim()),
121 ));
122 };
123 args.push((key.trim().to_string(), value.trim().to_string()));
124 }
125 }
126 rest = &rest[close + 1..];
127 }
128 let dest = match rest.trim_start().strip_prefix('=') {
129 Some("") => {
130 return Err(unwritten(
131 text,
132 "`=` names no destination; drop it to read the reading under \
133 `data.representations`",
134 ));
135 }
136 Some(path) => Some(PathBuf::from(path)),
137 None if rest.trim().is_empty() => None,
138 None => {
139 return Err(unwritten(
140 text,
141 &format!("`{}` follows the call", rest.trim()),
142 ));
143 }
144 };
145 Ok(Call { name, args, dest })
146}
147
148pub fn asked_all(calls: &[Call]) -> Result<Vec<Asked>, CliError> {
151 let mut out: Vec<Asked> = Vec::with_capacity(calls.len());
152 for call in calls {
153 let one = asked(call)?;
154 if out.iter().any(|held| held.name == one.name) {
155 return Err(CliError::usage(
156 "query.duplicate",
157 format!(
158 "`{}` is asked more than once; ask each reading once, every argument in \
159 its one call",
160 one.name
161 ),
162 ));
163 }
164 out.push(one);
165 }
166 Ok(out)
167}
168
169#[derive(Clone, Debug, PartialEq)]
171struct Time {
172 amount: f64,
173 unit: SpanUnit,
174 written: String,
175}
176
177impl Asked {
178 fn reading(&self) -> &'static Reading {
179 READINGS
180 .iter()
181 .find(|row| row.name == self.name)
182 .expect("an asked reading names a row")
183 }
184
185 pub fn off_samples(&self, sampled: bool) -> bool {
187 self.reading().reads.off_samples(sampled)
188 }
189
190 pub fn representation(
193 &self,
194 rate: u32,
195 seconds_per_bar: Option<f64>,
196 ) -> Result<Representation, CliError> {
197 let given = self.resolved(rate, seconds_per_bar)?;
198 let representation = self.reading().representation(&given);
199 pinned(representation, rate)?;
200 Ok(representation)
201 }
202
203 pub fn args(
208 &self,
209 rate: u32,
210 seconds_per_bar: Option<f64>,
211 taken: Taken,
212 ) -> Result<Vec<(&'static str, Option<Spelled>)>, CliError> {
213 use Representation as R;
214 let count = |n: usize| Some(Spelled::Number(n.to_string()));
215 let secs = |s: f64| Some(Spelled::Text(spelled_secs(s)));
216 let db = |d: f64| Some(Spelled::Text(format!("{d}db")));
217 let switch = |on: bool| Some(Spelled::Number(u8::from(on).to_string()));
218 let text = |t: &str| Some(Spelled::Text(t.to_string()));
219 let frame = |written: Option<f64>, unwritten: Option<f64>| match taken {
220 Taken::Exact => written.and_then(secs),
221 Taken::Measured { .. } => written.or(unwritten).and_then(secs),
222 };
223 let mut held = match self.representation(rate, seconds_per_bar)? {
224 R::Spectrum {
225 max_peaks,
226 frame_secs,
227 } => {
228 let fitted = match taken {
229 Taken::Measured { fitted_frame } => fitted_frame,
230 Taken::Exact => None,
231 };
232 let fitted = fitted.map(|samples| samples as f64 / f64::from(rate));
233 vec![
234 (Arg::Peaks, count(max_peaks)),
235 (Arg::Frame, frame(frame_secs, fitted)),
236 ]
237 }
238 R::Envelope { frame_secs } => {
239 vec![(Arg::Frame, frame(frame_secs, Some(DEFAULT_FRAME_SECS)))]
240 }
241 R::Ledger { depth } => vec![(Arg::Depth, count(depth))],
242 R::Pitch {
243 max_notes: peaks,
244 frame_secs,
245 }
246 | R::Formants {
247 max_formants: peaks,
248 frame_secs,
249 } => vec![(Arg::Peaks, count(peaks)), (Arg::Frame, secs(frame_secs))],
250 R::Stereo { frame_secs } => vec![(Arg::Frame, secs(frame_secs))],
251 R::Loudness { window_secs } => vec![(Arg::Window, secs(window_secs))],
252 R::Bands => vec![],
253 R::Alias { oversample } => vec![(Arg::Oversample, count(oversample as usize))],
254 R::Harmony {
255 reading,
256 frame_secs,
257 floor_db,
258 } => {
259 let own = match reading {
260 Harmonic::Dissonance { max_pairs } => Some((Arg::Pairs, count(max_pairs))),
261 Harmonic::VoiceLeading { norm } => Some((Arg::Norm, text(norm.key()))),
262 Harmonic::TonalDistance | Harmonic::Inharmonicity => None,
263 };
264 let shared = [(Arg::Frame, secs(frame_secs)), (Arg::Floor, db(floor_db))];
265 shared.into_iter().chain(own).collect()
266 }
267 R::Periodicity {
268 frame_secs,
269 window_secs,
270 curve,
271 } => vec![
272 (Arg::Frame, secs(frame_secs)),
273 (Arg::Window, secs(window_secs)),
274 (Arg::Curve, switch(curve)),
275 ],
276 R::Modulation {
277 frame_secs,
278 window_secs,
279 bpm,
280 } => vec![
281 (Arg::Frame, secs(frame_secs)),
282 (Arg::Window, secs(window_secs)),
283 (Arg::Bpm, bpm.map(|b| Spelled::Number(b.to_string()))),
284 ],
285 R::SelfSimilarity {
286 frame_secs,
287 cell_secs,
288 smooth_secs,
289 window_secs,
290 floor_db,
291 listed,
292 curve,
293 } => vec![
294 (Arg::Frame, secs(frame_secs)),
295 (Arg::Cell, secs(cell_secs)),
296 (Arg::Smooth, secs(smooth_secs)),
297 (Arg::Window, secs(window_secs)),
298 (Arg::Floor, db(floor_db)),
299 (Arg::Peaks, count(listed)),
300 (Arg::Curve, switch(curve)),
301 ],
302 R::Lines
303 | R::Atoms
304 | R::Derivative
305 | R::Samples
306 | R::Crest
307 | R::Onsets
308 | R::Bindings
309 | R::Arguments => Vec::new(),
310 R::Partials {
311 frame_secs,
312 window_secs,
313 hop_secs,
314 floor_db,
315 tracks,
316 } => vec![
317 (Arg::Frame, secs(frame_secs)),
318 (Arg::Window, secs(window_secs)),
319 (Arg::Hop, secs(hop_secs)),
320 (Arg::Floor, db(floor_db)),
321 (Arg::Peaks, count(tracks)),
322 ],
323 };
324 let buffer = match &self.dest {
325 Some(Destination::Alsa { buffer_secs, .. }) => secs(*buffer_secs),
326 _ => None,
327 };
328 held.extend([
329 (Arg::Brief, switch(self.brief)),
330 (Arg::Skim, switch(self.skim)),
331 (
332 Arg::Node,
333 self.node.as_ref().and_then(|n| text(&format!("@{n}"))),
334 ),
335 (Arg::Buffer, buffer),
336 ]);
337 Ok(self
338 .reading()
339 .takes
340 .iter()
341 .map(|arg| {
342 let (_, value) = held
343 .iter()
344 .find(|(one, _)| one == arg)
345 .expect("a reading holds every argument it takes");
346 (arg.key(), value.clone())
347 })
348 .collect())
349 }
350
351 fn resolved(&self, rate: u32, seconds_per_bar: Option<f64>) -> Result<Given, CliError> {
353 let mut given = self.given;
354 for (arg, time) in &self.times {
355 let secs = match (time.unit, seconds_per_bar) {
356 (SpanUnit::Seconds, _) => time.amount,
357 (SpanUnit::Bars, Some(per)) => time.amount * per,
358 (SpanUnit::Bars, None) => {
359 return Err(CliError::BadTempo(format!(
360 "`{}({}={})` is written in bars, and this reading has no tempo to \
361 count them in: a render reads it off `variables/bpm` and \
362 `variables/meter`, and a file holds none; write seconds",
363 self.name,
364 arg.key(),
365 time.written
366 )));
367 }
368 };
369 if matches!(arg, Arg::Frame | Arg::Hop) && (secs * f64::from(rate)).round() < 1.0 {
370 return Err(CliError::usage(
371 "query.bad_value",
372 format!(
373 "`{}({}={})` is {secs}s, under half a sample at {rate} Hz, and a \
374 {} steps by whole samples, rounded; ask at least {}s, half a sample",
375 self.name,
376 arg.key(),
377 time.written,
378 arg.key(),
379 0.5 / f64::from(rate)
380 ),
381 ));
382 }
383 let most = MOST_SAMPLES / f64::from(rate);
384 if secs > most {
385 return Err(CliError::usage(
386 "query.bad_value",
387 format!(
388 "`{}({}={})` is {secs}s, past the 2^53 samples a count holds exactly at \
389 {rate} Hz; ask at most {most}s",
390 self.name,
391 arg.key(),
392 time.written
393 ),
394 ));
395 }
396 match arg {
397 Arg::Frame => given.frame = Some(secs),
398 Arg::Window => given.window = Some(secs),
399 Arg::Cell => given.cell_secs = secs,
400 Arg::Smooth => given.smooth_secs = secs,
401 Arg::Hop => given.hop = Some(secs),
402 _ => unreachable!("only a time argument is held as a time"),
403 }
404 }
405 Ok(given)
406 }
407
408 pub fn path(&self) -> Option<&Path> {
409 match &self.dest {
410 Some(Destination::File(path)) => Some(path),
411 Some(Destination::Alsa { .. }) | None => None,
412 }
413 }
414}
415
416pub fn asked(call: &Call) -> Result<Asked, CliError> {
418 let name = call.name.as_str();
419 if let Some(write) = retired(name) {
420 return Err(CliError::usage(
421 "query.retired",
422 format!("`{name}` left the language; write `{write}`"),
423 ));
424 }
425 let Some(row) = READINGS.iter().find(|row| row.name == name) else {
426 let names: Vec<&str> = READINGS.iter().map(|row| row.name).collect();
427 return Err(CliError::usage(
428 "query.unknown_representation",
429 format!(
430 "unknown representation `{name}`; the representations are {}",
431 names.join(", ")
432 ),
433 ));
434 };
435 let (mut given, mut node, mut brief, mut skim) = (Given::default(), None, false, false);
436 let (mut buffer, mut times) = (None, Vec::new());
437 for (key, raw) in &call.args {
438 let Some(arg) = row.takes.iter().find(|arg| arg.key() == key) else {
439 let keys: Vec<&str> = row.takes.iter().map(|arg| arg.key()).collect();
440 return Err(CliError::usage(
441 "query.unknown_argument",
442 match keys[..] {
443 [] => format!("`{name}` takes no arguments, not `{key}`"),
444 _ => format!("`{name}` takes {}, not `{key}`", keys.join(", ")),
445 },
446 ));
447 };
448 match arg {
449 Arg::Peaks => given.peaks = Some(count(raw, key)?),
450 Arg::Depth => given.depth = count(raw, key)?,
451 Arg::Frame | Arg::Window | Arg::Cell | Arg::Smooth | Arg::Hop => {
452 times.push((*arg, time(raw, key)?));
453 }
454 Arg::Oversample => given.oversample = factor(raw)?,
455 Arg::Brief => brief = switch(raw, key)?,
456 Arg::Skim => skim = switch(raw, key)?,
457 Arg::Node => node = Some(self::node(raw)?),
458 Arg::Buffer => buffer = Some(microseconds(raw, key)?),
459 Arg::Floor => given.floor_db = Some(decibels(raw, key)?),
460 Arg::Norm => given.norm = norm(raw)?,
461 Arg::Pairs => given.max_pairs = count(raw, key)?,
462 Arg::Curve => given.curve = switch(raw, key)?,
463 Arg::Bpm => given.bpm = Some(tempo(raw, key)?),
464 }
465 }
466 if row.takes.contains(&Arg::Node) && node.is_none() {
467 return Err(CliError::usage(
468 "query.missing_argument",
469 format!("`{name}` reads one instance's {name}; name it, as `{name}(node=@voice)`"),
470 ));
471 }
472 Ok(Asked {
473 name: call.name.clone(),
474 given,
475 times,
476 dest: destination(name, call.dest.as_deref(), buffer)?,
477 node,
478 brief,
479 skim,
480 })
481}
482
483fn destination(
485 name: &str,
486 dest: Option<&Path>,
487 buffer: Option<f64>,
488) -> Result<Option<Destination>, CliError> {
489 let device = dest.and_then(|d| d.to_str()?.strip_prefix("alsa:"));
490 match (dest, device, buffer) {
491 (_, Some(""), _) => Err(CliError::usage(
492 "destination.no_device",
493 format!(
494 "`{name}=alsa:` names no device; write an ALSA device after it, as \
495 `alsa:default` or `alsa:hw:1`"
496 ),
497 )),
498 (_, Some(device), _) => Ok(Some(Destination::Alsa {
499 device: device.to_string(),
500 buffer_secs: buffer.unwrap_or(DEFAULT_BUFFER_SECS),
501 })),
502 (_, None, Some(_)) => Err(CliError::usage(
503 "query.buffer_without_device",
504 format!(
505 "`buffer` sizes a live device's buffer, and `{name}` names none; write \
506 `{name}(buffer=...)=alsa:<device>`, or drop `buffer`"
507 ),
508 )),
509 (dest, None, None) => Ok(dest.map(|d| Destination::File(d.to_path_buf()))),
510 }
511}
512
513fn split_outside(raw: &str, sep: char) -> Result<Vec<&str>, CliError> {
515 let (mut depth, mut from, mut out) = (0i32, 0, Vec::new());
516 for (at, c) in raw.char_indices() {
517 match c {
518 '(' => depth += 1,
519 ')' => depth -= 1,
520 c if c == sep && depth == 0 => {
521 out.push(&raw[from..at]);
522 from = at + c.len_utf8();
523 }
524 _ => {}
525 }
526 if depth < 0 {
527 return Err(unwritten(raw, "a `)` closes nothing"));
528 }
529 }
530 out.push(&raw[from..]);
531 Ok(out)
532}
533
534fn closing(text: &str) -> Option<usize> {
536 let mut depth = 0i32;
537 for (at, c) in text.char_indices() {
538 match c {
539 '(' => depth += 1,
540 ')' => {
541 depth -= 1;
542 if depth == 0 {
543 return Some(at);
544 }
545 }
546 _ => {}
547 }
548 }
549 None
550}
551
552fn unwritten(text: &str, why: &str) -> CliError {
553 CliError::usage(
554 "query.malformed",
555 format!(
556 "`{text}` is no reading: {why}. write a call, as `spectrum(peaks=8, frame=50ms)`, \
557 `=path` after it to write it to a file"
558 ),
559 )
560}
561
562fn count(raw: &str, key: &str) -> Result<usize, CliError> {
563 raw.parse::<usize>().map_err(|_| {
564 CliError::usage(
565 "query.bad_value",
566 format!("`{key}` needs a whole count, got `{raw}`"),
567 )
568 })
569}
570
571fn factor(raw: &str) -> Result<u32, CliError> {
572 match raw.parse::<u32>() {
573 Ok(k) if (2..=16).contains(&k) && k.is_power_of_two() => Ok(k),
574 _ => Err(CliError::usage(
575 "query.bad_value",
576 format!("`oversample` needs a power of two from 2 to 16, got `{raw}`"),
577 )),
578 }
579}
580
581fn time(raw: &str, key: &str) -> Result<Time, CliError> {
584 let bad = |why: String| {
585 CliError::usage(
586 "query.bad_value",
587 format!(
588 "`{key}` needs a positive time, in seconds as `50ms` or `0.05`, or in bars as \
589 `1b/8`: {why}"
590 ),
591 )
592 };
593 let folding = Folding::<f64> {
594 field: Field::Time,
595 mark: &|_| None,
596 degree: &|_| Err("a reading's time counts no degrees".to_string()),
597 own: None,
598 };
599 let value = evaluate(raw, &folding).map_err(|refused| match refused {
600 Refused::At(_, why) => bad(why),
601 Refused::Mark(name, _) => bad(format!(
602 "`mark({name})` is a score's, and a reading has none"
603 )),
604 })?;
605 let unit = match value.kind {
606 Kind::Bars => SpanUnit::Bars,
607 Kind::Seconds | Kind::Number => SpanUnit::Seconds,
608 other => return Err(bad(format!("`{raw}` is a {}", other.name()))),
609 };
610 match value.amount > 0.0 && value.amount.is_finite() {
611 true => Ok(Time {
612 amount: value.amount,
613 unit,
614 written: raw.to_string(),
615 }),
616 false => Err(bad(format!("`{raw}` is not over zero"))),
617 }
618}
619
620#[derive(Clone, Copy, Debug, PartialEq)]
622pub enum Taken {
623 Exact,
625 Measured { fitted_frame: Option<usize> },
627}
628
629#[derive(Clone, Debug, PartialEq)]
631pub enum Spelled {
632 Number(String),
633 Text(String),
634}
635
636impl Spelled {
637 pub fn text(&self) -> &str {
638 match self {
639 Spelled::Number(t) | Spelled::Text(t) => t,
640 }
641 }
642}
643
644fn spelled_secs(secs: f64) -> String {
647 let ms = secs * 1000.0;
648 match secs < 1.0 && ms / 1000.0 == secs {
649 true => format!("{ms}ms"),
650 false => format!("{secs}s"),
651 }
652}
653
654fn pinned(representation: Representation, rate: u32) -> Result<(), CliError> {
656 let Representation::Spectrum {
657 frame_secs: Some(secs),
658 ..
659 } = representation
660 else {
661 return Ok(());
662 };
663 let sr = f64::from(rate);
664 let frame = pinned_frame(secs, sr);
665 match frame > MAX_PINNED_FRAME {
666 true => Err(CliError::usage(
667 "query.bad_value",
668 format!(
669 "`spectrum(frame={secs})` sizes its transform to {frame} samples at {sr} Hz, \
670 past the {MAX_PINNED_FRAME}-sample bound"
671 ),
672 )),
673 false => Ok(()),
674 }
675}
676
677pub fn seconds(raw: &str, key: &str) -> Result<f64, CliError> {
679 let held = match sva_ast::tokenize(raw).as_deref() {
680 Ok([one]) => match one.kind {
681 sva_ast::TokenKind::Num(v)
682 | sva_ast::TokenKind::Time(v, sva_ast::SpanUnit::Seconds) => Some(v),
683 _ => None,
684 },
685 _ => None,
686 };
687 match held {
688 Some(v) if v > 0.0 && v.is_finite() => Ok(v),
689 _ => Err(CliError::usage(
690 "query.bad_value",
691 format!("`{key}` needs a positive time, as `50ms` or `0.05`, got `{raw}`"),
692 )),
693 }
694}
695
696fn tempo(raw: &str, key: &str) -> Result<f64, CliError> {
698 let held = match sva_ast::tokenize(raw).as_deref() {
699 Ok([one]) => match one.kind {
700 sva_ast::TokenKind::Num(v) => Some(v),
701 _ => None,
702 },
703 _ => None,
704 };
705 match held {
706 Some(v) if v > 0.0 && v.is_finite() => Ok(v),
707 _ => Err(CliError::usage(
708 "query.bad_value",
709 format!("`{key}` needs a positive tempo in beats per minute, as `140`, got `{raw}`"),
710 )),
711 }
712}
713
714fn microseconds(raw: &str, key: &str) -> Result<f64, CliError> {
716 let secs = seconds(raw, key)?;
717 match (1.0..=f64::from(i32::MAX)).contains(&(secs * 1e6).round()) {
718 true => Ok(secs),
719 false => Err(CliError::usage(
720 "query.bad_value",
721 format!(
722 "`{key}` is a whole number of microseconds from 1us to {}s, got `{raw}`",
723 i32::MAX / 1_000_000
724 ),
725 )),
726 }
727}
728
729fn decibels(raw: &str, key: &str) -> Result<f64, CliError> {
731 use sva_ast::{LogUnit::Decibels, TokenKind};
732 let kinds: Option<Vec<TokenKind>> = sva_ast::tokenize(raw)
733 .ok()
734 .map(|tokens| tokens.into_iter().map(|t| t.kind).collect());
735 let held = match kinds.as_deref() {
736 Some([TokenKind::Log(v, Decibels)]) => Some(*v),
737 Some([TokenKind::Minus, TokenKind::Log(v, Decibels)]) => Some(-v),
738 _ => None,
739 };
740 match held {
741 Some(v) if v.is_finite() => Ok(v),
742 _ => Err(CliError::usage(
743 "query.bad_value",
744 format!("`{key}` needs a level in decibels, as `-40db`, got `{raw}`"),
745 )),
746 }
747}
748
749fn norm(raw: &str) -> Result<Norm, CliError> {
750 [Norm::L1, Norm::L2]
751 .into_iter()
752 .find(|n| n.key() == raw)
753 .ok_or_else(|| {
754 CliError::usage(
755 "query.bad_value",
756 format!("`norm` is `l1` or `l2`, got `{raw}`"),
757 )
758 })
759}
760
761fn switch(raw: &str, key: &str) -> Result<bool, CliError> {
762 match raw {
763 "1" => Ok(true),
764 "0" => Ok(false),
765 _ => Err(CliError::usage(
766 "query.bad_value",
767 format!("`{key}` is `1` or `0`, got `{raw}`"),
768 )),
769 }
770}
771
772fn node(raw: &str) -> Result<String, CliError> {
774 match raw.strip_prefix('@') {
775 Some(path) if !path.is_empty() => Ok(path.to_string()),
776 _ => Err(CliError::usage(
777 "query.bad_value",
778 format!("`node` names an instance as a ref, as `@voice`, not `{raw}`"),
779 )),
780 }
781}