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