1use std::collections::{BTreeMap, HashSet};
21
22use crate::OptionDef;
23
24#[derive(Clone)]
27pub struct OptionSpec {
28 pub def: OptionDef,
30 pub required: bool,
32 pub default: Option<String>,
34 pub value_completion: Option<crate::ValueProvider>,
39}
40
41impl std::fmt::Debug for OptionSpec {
42 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43 f.debug_struct("OptionSpec")
44 .field("def", &self.def)
45 .field("required", &self.required)
46 .field("default", &self.default)
47 .field("value_completion", &self.value_completion.as_ref().map(|_| "<provider>"))
48 .finish()
49 }
50}
51
52impl OptionSpec {
53 pub fn new(def: OptionDef) -> Self {
54 OptionSpec { def, required: false, default: None, value_completion: None }
55 }
56 pub fn required(mut self, yes: bool) -> Self {
57 self.required = yes;
58 self
59 }
60 pub fn default(mut self, v: impl Into<String>) -> Self {
61 self.default = Some(v.into());
62 self
63 }
64 pub fn value_completion(mut self, provider: crate::ValueProvider) -> Self {
66 self.value_completion = Some(provider);
67 self
68 }
69 pub fn flag(&self) -> &str {
71 &self.def.name
72 }
73}
74
75#[derive(Clone, Debug)]
77pub struct PositionalSpec {
78 pub name: String,
80 pub required: bool,
81 pub multiple: bool,
83 pub help: Option<String>,
84}
85
86impl PositionalSpec {
87 pub fn new(name: impl Into<String>) -> Self {
88 PositionalSpec { name: name.into(), required: false, multiple: false, help: None }
89 }
90 pub fn required(mut self, yes: bool) -> Self {
91 self.required = yes;
92 self
93 }
94 pub fn multiple(mut self, yes: bool) -> Self {
95 self.multiple = yes;
96 self
97 }
98 pub fn help(mut self, h: impl Into<String>) -> Self {
99 self.help = Some(h.into());
100 self
101 }
102}
103
104#[derive(Clone, Debug, Default)]
107pub struct CommandSpec {
108 pub name: String,
109 pub about: Option<String>,
110 pub aliases: Vec<String>,
111 pub options: Vec<OptionSpec>,
112 pub positionals: Vec<PositionalSpec>,
113 pub subcommands: Vec<CommandSpec>,
114 pub subcommand_required: bool,
116 pub after_help: Option<String>,
119 pub stability: crate::Stability,
123}
124
125impl CommandSpec {
126 pub fn new(name: impl Into<String>) -> Self {
127 CommandSpec { name: name.into(), ..Default::default() }
128 }
129 pub fn about(mut self, a: impl Into<String>) -> Self {
130 self.about = Some(a.into());
131 self
132 }
133 pub fn alias(mut self, a: impl Into<String>) -> Self {
135 self.aliases.push(a.into());
136 self
137 }
138 pub fn after_help(mut self, a: impl Into<String>) -> Self {
139 self.after_help = Some(a.into());
140 self
141 }
142 pub fn stability(mut self, s: crate::Stability) -> Self {
144 self.stability = s;
145 self
146 }
147 pub fn option(mut self, o: OptionSpec) -> Self {
148 self.options.push(o);
149 self
150 }
151 pub fn positional(mut self, p: PositionalSpec) -> Self {
152 self.positionals.push(p);
153 self
154 }
155 pub fn subcommand(mut self, c: CommandSpec) -> Self {
156 self.subcommands.push(c);
157 self
158 }
159
160 fn find_long(&self, token: &str) -> Option<&OptionSpec> {
162 let want = token.trim_start_matches('-');
163 self.options.iter().find(|o| o.def.name.trim_start_matches('-') == want)
164 }
165 fn find_short(&self, c: char) -> Option<&OptionSpec> {
166 self.options.iter().find(|o| o.def.short == Some(c))
167 }
168 fn find_subcommand(&self, name: &str) -> Option<&CommandSpec> {
169 self.subcommands
170 .iter()
171 .find(|s| s.name == name || s.aliases.iter().any(|a| a == name))
172 }
173}
174
175#[derive(Clone, Debug, Default)]
178pub struct ParsedArgs {
179 flags: HashSet<String>,
181 values: BTreeMap<String, Vec<String>>,
183 positionals: Vec<String>,
185 subcommand: Option<(String, Box<ParsedArgs>)>,
187}
188
189impl ParsedArgs {
190 pub fn has_flag(&self, name: &str) -> bool {
191 self.flags.contains(name.trim_start_matches('-'))
192 }
193 pub fn value(&self, name: &str) -> Option<&str> {
195 self.values.get(name.trim_start_matches('-')).and_then(|v| v.first()).map(|s| s.as_str())
196 }
197 pub fn values(&self, name: &str) -> &[String] {
199 const EMPTY: &[String] = &[];
200 self.values.get(name.trim_start_matches('-')).map(|v| v.as_slice()).unwrap_or(EMPTY)
201 }
202 pub fn positionals(&self) -> &[String] {
203 &self.positionals
204 }
205 pub fn subcommand(&self) -> Option<(&str, &ParsedArgs)> {
206 self.subcommand.as_ref().map(|(n, p)| (n.as_str(), p.as_ref()))
207 }
208}
209
210#[derive(Clone, Debug, PartialEq, Eq)]
212pub enum ParseError {
213 UnknownFlag { command: String, flag: String },
214 MissingValue { command: String, flag: String },
215 MissingRequiredOption { command: String, flag: String },
216 MissingRequiredPositional { command: String, name: String },
217 UnexpectedPositional { command: String, value: String },
218 UnknownSubcommand { command: String, name: String },
219 MissingSubcommand { command: String },
220 InvalidValue { flag: String, value: String, message: String },
223 ConflictingOptions { command: String, flag: String, other: String },
226}
227
228pub trait VeksCli: Sized {
232 fn veks_command_spec(name: &str) -> CommandSpec;
234 fn veks_augment_spec(spec: CommandSpec) -> CommandSpec;
237 fn veks_from_parsed(parsed: &ParsedArgs) -> Result<Self, ParseError>;
239}
240
241impl std::fmt::Display for ParseError {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 match self {
244 ParseError::UnknownFlag { command, flag } =>
245 write!(f, "{command}: unexpected option '{flag}'"),
246 ParseError::MissingValue { command, flag } =>
247 write!(f, "{command}: option '{flag}' requires a value"),
248 ParseError::MissingRequiredOption { command, flag } =>
249 write!(f, "{command}: required option '{flag}' not provided"),
250 ParseError::MissingRequiredPositional { command, name } =>
251 write!(f, "{command}: required argument <{name}> not provided"),
252 ParseError::UnexpectedPositional { command, value } =>
253 write!(f, "{command}: unexpected argument '{value}'"),
254 ParseError::UnknownSubcommand { command, name } =>
255 write!(f, "{command}: unknown subcommand '{name}'"),
256 ParseError::MissingSubcommand { command } =>
257 write!(f, "{command}: a subcommand is required"),
258 ParseError::InvalidValue { flag, value, message } =>
259 write!(f, "invalid value '{value}' for '{flag}': {message}"),
260 ParseError::ConflictingOptions { command, flag, other } =>
261 write!(f, "'{flag}' cannot be combined with '{other}' (in '{command}')"),
262 }
263 }
264}
265
266impl std::error::Error for ParseError {}
267
268pub fn parse(spec: &CommandSpec, argv: &[String]) -> Result<ParsedArgs, ParseError> {
276 let mut out = ParsedArgs::default();
277 let mut i = 0;
278 let mut options_ended = false;
279
280 while i < argv.len() {
281 let arg = &argv[i];
282
283 if !options_ended && arg == "--" {
284 options_ended = true;
285 i += 1;
286 continue;
287 }
288
289 if !options_ended && arg.starts_with("---") {
294 i += 1;
295 continue;
296 }
297
298 if !options_ended && arg.starts_with("--") {
299 let body = &arg[2..];
301 let (name, inline) = match body.split_once('=') {
302 Some((n, v)) => (n, Some(v.to_string())),
303 None => (body, None),
304 };
305 let opt = spec
306 .find_long(name)
307 .ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
308 let canon = opt.def.name.trim_start_matches('-').to_string();
309 if !opt.def.takes_value {
310 out.flags.insert(canon);
311 } else {
312 let value = match inline {
313 Some(v) => v,
314 None => {
315 i += 1;
316 argv.get(i)
317 .cloned()
318 .ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
319 }
320 };
321 out.values.entry(canon).or_default().push(value);
322 }
323 i += 1;
324 continue;
325 }
326
327 if !options_ended && arg.starts_with('-') && arg.len() > 1 {
328 let body = &arg[1..];
330 let mut chars = body.chars();
331 let short = chars.next().unwrap();
332 let rest: String = chars.collect();
333 let opt = spec
334 .find_short(short)
335 .ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
336 let canon = opt.def.name.trim_start_matches('-').to_string();
337 if !opt.def.takes_value {
338 out.flags.insert(canon);
339 } else {
340 let value = if let Some(stripped) = rest.strip_prefix('=') {
341 stripped.to_string()
342 } else if !rest.is_empty() {
343 rest
344 } else {
345 i += 1;
346 argv.get(i)
347 .cloned()
348 .ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
349 };
350 out.values.entry(canon).or_default().push(value);
351 }
352 i += 1;
353 continue;
354 }
355
356 if !spec.subcommands.is_empty() && out.positionals.is_empty() {
360 let sub = spec.find_subcommand(arg).ok_or_else(|| ParseError::UnknownSubcommand {
361 command: spec.name.clone(),
362 name: arg.clone(),
363 })?;
364 let sub_parsed = parse(sub, &argv[i + 1..])?;
365 out.subcommand = Some((sub.name.clone(), Box::new(sub_parsed)));
366 finalize(spec, &mut out)?;
368 return Ok(out);
369 }
370
371 out.positionals.push(arg.clone());
372 i += 1;
373 }
374
375 finalize(spec, &mut out)?;
376 Ok(out)
377}
378
379fn finalize(spec: &CommandSpec, out: &mut ParsedArgs) -> Result<(), ParseError> {
382 for opt in &spec.options {
385 let canon = opt.def.name.trim_start_matches('-');
386 if !out.flags.contains(canon) && !out.values.contains_key(canon) {
387 continue;
388 }
389 for conflict in &opt.def.conflicts_with {
390 let other = conflict.trim_start_matches('-');
391 if out.flags.contains(other) || out.values.contains_key(other) {
392 return Err(ParseError::ConflictingOptions {
393 command: spec.name.clone(),
394 flag: opt.def.name.clone(),
395 other: conflict.clone(),
396 });
397 }
398 }
399 }
400
401 for opt in &spec.options {
402 let canon = opt.def.name.trim_start_matches('-').to_string();
403 let present = out.flags.contains(&canon) || out.values.contains_key(&canon);
404 if !present {
405 if let Some(def) = &opt.default {
406 out.values.entry(canon.clone()).or_default().push(def.clone());
407 } else if opt.required {
408 return Err(ParseError::MissingRequiredOption {
409 command: spec.name.clone(),
410 flag: opt.def.name.clone(),
411 });
412 }
413 }
414 }
415
416 let required_positionals = spec.positionals.iter().filter(|p| p.required).count();
418 if out.positionals.len() < required_positionals {
419 let missing = &spec.positionals[out.positionals.len()];
420 return Err(ParseError::MissingRequiredPositional {
421 command: spec.name.clone(),
422 name: missing.name.clone(),
423 });
424 }
425
426 if spec.subcommand_required && out.subcommand.is_none() {
427 return Err(ParseError::MissingSubcommand { command: spec.name.clone() });
428 }
429
430 Ok(())
431}
432
433const HELP_WIDTH: usize = 100;
436
437fn wrap_text(text: &str, width: usize) -> Vec<String> {
439 let mut lines = Vec::new();
440 for para in text.split('\n') {
441 if para.trim().is_empty() {
442 lines.push(String::new());
443 continue;
444 }
445 let mut cur = String::new();
446 for word in para.split_whitespace() {
447 if cur.is_empty() {
448 cur.push_str(word);
449 } else if width == 0 || cur.len() + 1 + word.len() <= width {
450 cur.push(' ');
451 cur.push_str(word);
452 } else {
453 lines.push(std::mem::take(&mut cur));
454 cur.push_str(word);
455 }
456 }
457 lines.push(cur);
458 }
459 if lines.is_empty() {
460 lines.push(String::new());
461 }
462 lines
463}
464
465fn render_two_col(out: &mut String, rows: &[(String, String)]) {
469 if rows.is_empty() {
470 return;
471 }
472 let col = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0).min(28);
473 let help_width = HELP_WIDTH.saturating_sub(col + 4).max(20);
474 for (left, help) in rows {
475 let wrapped = wrap_text(help, help_width);
476 let mut iter = wrapped.iter();
477 let first = iter.next().map(|s| s.as_str()).unwrap_or("");
478 if left.len() <= col {
479 out.push_str(&format!(" {:<col$} {}\n", left, first, col = col));
480 } else {
481 out.push_str(&format!(" {}\n", left));
483 out.push_str(&format!(" {:<col$} {}\n", "", first, col = col));
484 }
485 for cont in iter {
486 out.push_str(&format!(" {:<col$} {}\n", "", cont, col = col));
487 }
488 }
489}
490
491pub fn render_help_for<S: AsRef<str>>(root: &CommandSpec, argv: &[S]) -> String {
496 let mut spec = root;
497 for word in argv {
498 let word = word.as_ref();
499 if word.starts_with('-') {
500 break;
501 }
502 match spec.find_subcommand(word) {
503 Some(sub) => spec = sub,
504 None => break,
505 }
506 }
507 render_help(spec)
508}
509
510pub fn render_help(spec: &CommandSpec) -> String {
514 let mut s = String::new();
515
516 if let Some(about) = &spec.about {
517 for line in wrap_text(about, HELP_WIDTH) {
518 s.push_str(&line);
519 s.push('\n');
520 }
521 s.push('\n');
522 }
523
524 s.push_str(&format!("Usage: {}", spec.name));
526 if !spec.options.is_empty() {
527 s.push_str(" [OPTIONS]");
528 }
529 for p in &spec.positionals {
530 let token = if p.multiple {
531 format!("[{}]...", p.name)
532 } else if p.required {
533 format!("<{}>", p.name)
534 } else {
535 format!("[{}]", p.name)
536 };
537 s.push(' ');
538 s.push_str(&token);
539 }
540 if !spec.subcommands.is_empty() {
541 s.push_str(" <COMMAND>");
542 }
543 s.push('\n');
544
545 if !spec.aliases.is_empty() {
546 s.push_str(&format!("\nAliases: {}\n", spec.aliases.join(", ")));
547 }
548
549 if !spec.subcommands.is_empty() {
550 s.push_str("\nCommands:\n");
551 let rows: Vec<(String, String)> = spec
552 .subcommands
553 .iter()
554 .map(|c| {
555 let name = if c.aliases.is_empty() {
556 c.name.clone()
557 } else {
558 format!("{}, {}", c.name, c.aliases.join(", "))
559 };
560 (name, c.about.clone().unwrap_or_default())
561 })
562 .collect();
563 render_two_col(&mut s, &rows);
564 }
565
566 if !spec.positionals.is_empty() {
567 s.push_str("\nArguments:\n");
568 let rows: Vec<(String, String)> = spec
569 .positionals
570 .iter()
571 .map(|p| (format!("<{}>", p.name), p.help.clone().unwrap_or_default()))
572 .collect();
573 render_two_col(&mut s, &rows);
574 }
575
576 {
577 s.push_str("\nOptions:\n");
578 let mut rows: Vec<(String, String)> = spec
579 .options
580 .iter()
581 .map(|o| {
582 let mut f = match o.def.short {
584 Some(sh) => format!("-{}, ", sh),
585 None => " ".to_string(),
586 };
587 f.push_str(&o.def.name);
588 if o.def.takes_value {
589 f.push_str(&format!(" <{}>", o.def.value_name.as_deref().unwrap_or("VALUE")));
590 }
591 (f, o.def.help.clone().unwrap_or_default())
592 })
593 .collect();
594 rows.push(("-h, --help".to_string(), "Print help".to_string()));
595 render_two_col(&mut s, &rows);
596 }
597
598 if let Some(after) = &spec.after_help {
599 s.push('\n');
600 s.push_str(after.trim_end());
601 s.push('\n');
602 }
603
604 s
605}
606
607pub fn build_completion_tree(
619 spec: &CommandSpec,
620 resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
621) -> crate::CommandTree {
622 let mut tree = crate::CommandTree::new(&spec.name);
623 tree.root = spec_to_node(spec, resolvers, "");
624 tree
625}
626
627fn spec_to_node(
631 spec: &CommandSpec,
632 resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
633 path: &str,
634) -> crate::Node {
635 if spec.subcommands.is_empty() {
636 let value_flags: Vec<&str> =
637 spec.options.iter().filter(|o| o.def.takes_value).map(|o| o.def.name.as_str()).collect();
638 let boolean_flags: Vec<&str> =
639 spec.options.iter().filter(|o| !o.def.takes_value).map(|o| o.def.name.as_str()).collect();
640 let mut node = crate::Node::leaf_with_flags(&value_flags, &boolean_flags);
641 for o in &spec.options {
642 if let Some(h) = &o.def.help {
643 node = node.with_flag_help(&o.def.name, h);
644 }
645 if let Some(c) = o.def.short {
650 node = node.with_short_alias(&format!("-{c}"), &o.def.name);
651 }
652 if o.def.takes_value {
653 let provider = o
656 .value_completion
657 .clone()
658 .or_else(|| resolvers.get(&o.def.name).cloned());
659 if let Some(p) = provider {
660 node = node.with_value_provider(&o.def.name, p);
661 }
662 }
663 }
664 for o in &spec.options {
668 for c in &o.def.conflicts_with {
669 node = node
670 .with_flag_conflict(&o.def.name, c)
671 .with_flag_conflict(c, &o.def.name);
672 }
673 }
674 if !spec.positionals.is_empty()
678 && let Some(p) = resolvers.get(path).cloned() {
679 node = node
680 .with_positional_provider(p)
681 .with_positional_slots(spec.positionals.len());
682 }
683 node.with_stability(spec.stability)
684 } else {
685 let mut node = crate::Node::empty_group();
686 for sub in &spec.subcommands {
687 let child_path =
688 if path.is_empty() { sub.name.clone() } else { format!("{path} {}", sub.name) };
689 node = node.with_child(&sub.name, spec_to_node(sub, resolvers, &child_path));
690 }
691 node.with_stability(spec.stability)
692 }
693}
694
695#[cfg(test)]
696mod tests {
697 use super::*;
698
699 fn vopt(name: &str) -> OptionSpec {
700 OptionSpec::new(OptionDef::value(name))
701 }
702 fn fopt(name: &str) -> OptionSpec {
703 OptionSpec::new(OptionDef::flag(name))
704 }
705
706 fn datasets_ping() -> CommandSpec {
707 CommandSpec::new("ping")
708 .about("Ping a remote dataset")
709 .option(vopt("--at").def_multiple())
710 .option(OptionSpec::new(OptionDef::value("--dataset")).required(true))
711 .option(OptionSpec::new(OptionDef::value("--profile")).default("default"))
712 }
713
714 impl OptionSpec {
716 fn def_multiple(mut self) -> Self {
717 self.def = self.def.multiple(true);
718 self
719 }
720 }
721
722 fn argv(s: &[&str]) -> Vec<String> {
723 s.iter().map(|x| x.to_string()).collect()
724 }
725
726 fn dim_family() -> CommandSpec {
729 CommandSpec::new("list")
730 .option(OptionSpec::new(
731 OptionDef::value("--with-dim")
732 .conflicts_with(&["--with-min-dim", "--with-max-dim"]),
733 ))
734 .option(vopt("--with-min-dim"))
735 .option(vopt("--with-max-dim"))
736 }
737
738 #[test]
739 fn conflicting_flags_withheld_from_completion_both_directions() {
740 let spec = dim_family();
741 let tree = build_completion_tree(&spec, &std::collections::BTreeMap::new());
742
743 let cands = crate::complete(&tree, &["list", "--with-dim", "5", "--with-"]);
745 assert!(!cands.iter().any(|c| c == "--with-min-dim"), "{cands:?}");
746 assert!(!cands.iter().any(|c| c == "--with-max-dim"), "{cands:?}");
747
748 let cands = crate::complete(&tree, &["list", "--with-min-dim", "4", "--with-"]);
751 assert!(!cands.iter().any(|c| c == "--with-dim"), "{cands:?}");
752 assert!(cands.iter().any(|c| c == "--with-max-dim"), "{cands:?}");
753 }
754
755 #[test]
756 fn conflicting_options_rejected_at_parse_in_either_order() {
757 let spec = dim_family();
758 for words in [
759 ["--with-dim", "5", "--with-min-dim", "4"],
760 ["--with-min-dim", "4", "--with-dim", "5"],
761 ] {
762 let err = parse(&spec, &argv(&words)).unwrap_err();
763 assert!(
764 matches!(err, ParseError::ConflictingOptions { .. }),
765 "expected conflict error, got {err:?}"
766 );
767 }
768 assert!(parse(&spec, &argv(&["--with-min-dim", "4", "--with-max-dim", "9"])).is_ok());
770 }
771
772 #[test]
773 fn parses_value_space_and_equals_forms() {
774 let spec = datasets_ping();
775 let p = parse(&spec, &argv(&["--dataset", "glove", "--at", "1"])).unwrap();
776 assert_eq!(p.value("--dataset"), Some("glove"));
777 assert_eq!(p.values("--at"), &["1".to_string()]);
778 let p2 = parse(&spec, &argv(&["--dataset=glove"])).unwrap();
779 assert_eq!(p2.value("--dataset"), Some("glove"));
780 }
781
782 #[test]
783 fn repeatable_option_accumulates() {
784 let spec = datasets_ping();
785 let p = parse(&spec, &argv(&["--dataset", "d", "--at", "1", "--at", "2"])).unwrap();
786 assert_eq!(p.values("--at"), &["1".to_string(), "2".to_string()]);
787 }
788
789 #[test]
790 fn default_applies_when_absent() {
791 let spec = datasets_ping();
792 let p = parse(&spec, &argv(&["--dataset", "d"])).unwrap();
793 assert_eq!(p.value("--profile"), Some("default"));
794 }
795
796 #[test]
797 fn required_option_missing_errors() {
798 let spec = datasets_ping();
799 let err = parse(&spec, &argv(&["--at", "1"])).unwrap_err();
800 assert_eq!(err, ParseError::MissingRequiredOption { command: "ping".into(), flag: "--dataset".into() });
801 }
802
803 #[test]
804 fn unknown_flag_errors() {
805 let spec = datasets_ping();
806 let err = parse(&spec, &argv(&["--dataset", "d", "--nope"])).unwrap_err();
807 assert!(matches!(err, ParseError::UnknownFlag { .. }));
808 }
809
810 #[test]
811 fn boolean_flag_takes_no_value() {
812 let spec = CommandSpec::new("list").option(fopt("--verbose"));
813 let p = parse(&spec, &argv(&["--verbose"])).unwrap();
814 assert!(p.has_flag("--verbose"));
815 let p2 = parse(&spec, &argv(&["--verbose", "x"])).unwrap();
817 assert_eq!(p2.positionals(), &["x".to_string()]);
818 }
819
820 #[test]
821 fn double_dash_ends_options() {
822 let spec = CommandSpec::new("run").option(fopt("--flag"));
823 let p = parse(&spec, &argv(&["--", "--flag"])).unwrap();
824 assert!(!p.has_flag("--flag"));
825 assert_eq!(p.positionals(), &["--flag".to_string()]);
826 }
827
828 #[test]
829 fn subcommand_dispatch_and_short_value() {
830 let spec = CommandSpec::new("datasets")
831 .subcommand(datasets_ping())
832 .subcommand(
833 CommandSpec::new("derive")
834 .option(OptionSpec::new(OptionDef::value("--output").short('o')).required(true)),
835 );
836 let p = parse(&spec, &argv(&["derive", "-o", "/tmp/out"])).unwrap();
837 let (name, sub) = p.subcommand().unwrap();
838 assert_eq!(name, "derive");
839 assert_eq!(sub.value("--output"), Some("/tmp/out"));
840 }
841
842 #[test]
843 fn unknown_subcommand_errors() {
844 let spec = CommandSpec::new("datasets").subcommand(datasets_ping());
845 let err = parse(&spec, &argv(&["frobnicate"])).unwrap_err();
846 assert!(matches!(err, ParseError::UnknownSubcommand { .. }));
847 }
848
849 #[test]
850 fn completion_tree_built_from_spec() {
851 let spec = CommandSpec::new("veks").subcommand(
852 CommandSpec::new("datasets")
853 .subcommand(
854 CommandSpec::new("ping")
855 .option(vopt("--at").def_multiple())
856 .option(vopt("--dataset")),
857 )
858 .subcommand(CommandSpec::new("list").option(fopt("--verbose"))),
859 );
860 let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
861 std::collections::BTreeMap::new();
862 resolvers.insert(
863 "--at".to_string(),
864 crate::fn_provider(|_p, _c| vec!["1".to_string(), "2".to_string()]),
865 );
866 let tree = build_completion_tree(&spec, &resolvers);
867
868 let ping_flags = crate::complete(&tree, &["veks", "datasets", "ping", "--"]);
870 assert!(ping_flags.contains(&"--at".to_string()));
871 assert!(ping_flags.contains(&"--dataset".to_string()));
872 let list_flags = crate::complete(&tree, &["veks", "datasets", "list", "--"]);
873 assert!(list_flags.contains(&"--verbose".to_string()));
874 assert!(!list_flags.contains(&"--at".to_string()), "--at must not leak onto list");
875
876 let at_vals = crate::complete(&tree, &["veks", "datasets", "ping", "--at", ""]);
878 assert_eq!(at_vals, vec!["1".to_string(), "2".to_string()]);
879 }
880
881 #[test]
886 fn two_slot_positional_completion() {
887 let spec = CommandSpec::new("vectordata").subcommand(
888 CommandSpec::new("config").subcommand(
889 CommandSpec::new("set")
890 .positional(PositionalSpec::new("key"))
891 .positional(PositionalSpec::new("value"))
892 .option(OptionSpec::new(OptionDef::flag("--force"))),
893 ),
894 );
895 let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
896 std::collections::BTreeMap::new();
897 resolvers.insert(
898 "config set".to_string(),
899 crate::fn_provider(|p, ctx| {
900 let positionals: Vec<&&str> =
901 ctx.iter().filter(|w| !w.starts_with('-')).collect();
902 let cands: Vec<&str> = match positionals.first() {
903 None => vec!["cache"],
904 Some(&&"cache") => vec!["auto", "/data/vectordata-cache"],
905 Some(_) => vec![],
906 };
907 cands.iter()
908 .filter(|c| p.is_empty() || c.starts_with(p))
909 .map(|c| c.to_string())
910 .collect()
911 }),
912 );
913 let tree = build_completion_tree(&spec, &resolvers);
914
915 let keys = crate::complete(&tree, &["vectordata", "config", "set", ""]);
917 assert!(keys.contains(&"cache".to_string()), "{keys:?}");
918 let vals = crate::complete(&tree, &["vectordata", "config", "set", "cache", ""]);
920 assert!(vals.contains(&"auto".to_string()), "{vals:?}");
921 assert!(vals.contains(&"/data/vectordata-cache".to_string()), "{vals:?}");
922 let done = crate::complete(&tree, &["vectordata", "config", "set", "cache", "auto", ""]);
924 assert!(!done.contains(&"auto".to_string()), "{done:?}");
925 let vals2 = crate::complete(&tree, &["vectordata", "config", "set", "--force", "cache", ""]);
927 assert!(vals2.contains(&"auto".to_string()), "{vals2:?}");
928 }
929
930 #[test]
935 fn short_flag_value_completes_like_long_form() {
936 let spec = CommandSpec::new("veks").subcommand(
937 CommandSpec::new("attach")
938 .option(OptionSpec::new(OptionDef::value("--config").short('c')))
939 .option(OptionSpec::new(OptionDef::flag("--verbose").short('v'))),
940 );
941 let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
942 std::collections::BTreeMap::new();
943 resolvers.insert(
944 "--config".to_string(),
945 crate::fn_provider(|p, _c| {
946 ["dev.yaml", "prod.yaml"].iter()
947 .filter(|v| v.starts_with(p))
948 .map(|v| v.to_string())
949 .collect()
950 }),
951 );
952 let tree = build_completion_tree(&spec, &resolvers);
953
954 let long_vals = crate::complete(&tree, &["veks", "attach", "--config", ""]);
956 let short_vals = crate::complete(&tree, &["veks", "attach", "-c", ""]);
957 assert_eq!(long_vals, vec!["dev.yaml".to_string(), "prod.yaml".to_string()]);
958 assert_eq!(short_vals, long_vals,
959 "short flag must value-complete like its long form");
960
961 let filtered = crate::complete(&tree, &["veks", "attach", "-c", "pro"]);
963 assert_eq!(filtered, vec!["prod.yaml".to_string()]);
964
965 let after_bool = crate::complete(&tree, &["veks", "attach", "-v", "--"]);
968 assert!(after_bool.contains(&"--config".to_string()),
969 "boolean short must not open a value position: {after_bool:?}");
970
971 let after_number = crate::complete(&tree, &["veks", "attach", "-5", "--"]);
974 assert!(after_number.contains(&"--config".to_string()),
975 "unregistered -word must not open a value position: {after_number:?}");
976 }
977}
978
979#[cfg(test)]
980mod derive_tests {
981 use crate::VeksCli;
982 use veks_completion_derive::VeksCli;
983
984 fn argv(s: &[&str]) -> Vec<String> {
985 s.iter().map(|x| x.to_string()).collect()
986 }
987
988 #[derive(VeksCli, Debug, PartialEq)]
989 #[command(about = "Ping a remote dataset")]
990 struct Ping {
991 #[arg(long = "at")]
993 at: Vec<String>,
994 #[arg(long)]
995 dataset: String,
996 #[arg(long, default = "default")]
997 profile: String,
998 #[arg(long)]
999 verbose: bool,
1000 }
1001
1002 #[test]
1003 fn derive_struct_spec_and_extract() {
1004 let spec = Ping::veks_command_spec("ping");
1005 assert_eq!(spec.about.as_deref(), Some("Ping a remote dataset"));
1007 let p = crate::cli::parse(
1008 &spec,
1009 &argv(&["--dataset", "glove", "--at", "1", "--at", "2", "--verbose"]),
1010 )
1011 .unwrap();
1012 let ping = Ping::veks_from_parsed(&p).unwrap();
1013 assert_eq!(
1014 ping,
1015 Ping {
1016 at: vec!["1".into(), "2".into()],
1017 dataset: "glove".into(),
1018 profile: "default".into(),
1019 verbose: true,
1020 }
1021 );
1022 }
1023
1024 #[test]
1025 fn derive_typed_conversion_and_default() {
1026 #[derive(VeksCli, Debug, PartialEq)]
1027 struct Run {
1028 #[arg(long, default = "4")]
1029 threads: usize,
1030 #[arg(long)]
1031 tag: Option<String>,
1032 }
1033 let spec = Run::veks_command_spec("run");
1034 let p = crate::cli::parse(&spec, &argv(&["--threads", "8"])).unwrap();
1035 let run = Run::veks_from_parsed(&p).unwrap();
1036 assert_eq!(run, Run { threads: 8, tag: None });
1037 let p2 = crate::cli::parse(&spec, &argv(&[])).unwrap();
1039 assert_eq!(Run::veks_from_parsed(&p2).unwrap().threads, 4);
1040 let p3 = crate::cli::parse(&spec, &argv(&["--threads", "abc"])).unwrap();
1042 assert!(matches!(
1043 Run::veks_from_parsed(&p3),
1044 Err(crate::cli::ParseError::InvalidValue { .. })
1045 ));
1046 }
1047
1048 #[derive(VeksCli, Debug, PartialEq)]
1049 enum Cmd {
1050 Ping(Ping),
1051 List {
1053 #[arg(long)]
1054 verbose: bool,
1055 },
1056 }
1057
1058 #[derive(VeksCli)]
1062 #[command(stability = "preview")]
1063 struct PreviewArgs {
1064 #[arg(long)]
1065 #[allow(dead_code)]
1066 x: bool,
1067 }
1068
1069 #[derive(VeksCli)]
1070 enum StabilityCmd {
1071 Steady {
1073 #[arg(long)]
1074 #[allow(dead_code)]
1075 a: bool,
1076 },
1077 #[command(stability = "experimental")]
1078 Risky {
1079 #[arg(long)]
1080 #[allow(dead_code)]
1081 b: bool,
1082 },
1083 }
1084
1085 #[test]
1086 fn derive_reads_command_stability() {
1087 use crate::Stability;
1088 assert_eq!(
1090 PreviewArgs::veks_command_spec("preview-args").stability,
1091 Stability::Preview
1092 );
1093 let spec = StabilityCmd::veks_command_spec("app");
1095 let steady = spec.subcommands.iter().find(|c| c.name == "steady").unwrap();
1096 let risky = spec.subcommands.iter().find(|c| c.name == "risky").unwrap();
1097 assert_eq!(steady.stability, Stability::Stable);
1098 assert_eq!(risky.stability, Stability::Experimental);
1099 }
1100
1101 #[test]
1102 fn derive_enum_subcommand_dispatch() {
1103 let spec = Cmd::veks_command_spec("veks");
1104 assert!(spec.subcommand_required);
1105 let p = crate::cli::parse(&spec, &argv(&["ping", "--dataset", "d"])).unwrap();
1107 match Cmd::veks_from_parsed(&p).unwrap() {
1108 Cmd::Ping(ping) => assert_eq!(ping.dataset, "d"),
1109 _ => panic!("expected Ping"),
1110 }
1111 let p2 = crate::cli::parse(&spec, &argv(&["list", "--verbose"])).unwrap();
1113 assert_eq!(Cmd::veks_from_parsed(&p2).unwrap(), Cmd::List { verbose: true });
1114 }
1115
1116 #[test]
1117 fn completion_hides_commands_below_stability_threshold() {
1118 use crate::{CommandSpec, Stability};
1119 let spec = CommandSpec::new("app")
1120 .subcommand(CommandSpec::new("stable-cmd"))
1121 .subcommand(CommandSpec::new("preview-cmd").stability(Stability::Preview))
1122 .subcommand(CommandSpec::new("exp-cmd").stability(Stability::Experimental));
1123 let resolvers = std::collections::BTreeMap::new();
1124 let mut tree = crate::cli::build_completion_tree(&spec, &resolvers);
1125
1126 let has = |t: &crate::CommandTree, name: &str| {
1127 crate::complete_at_tap_with_raw(t, &["app", ""], 1, "app ", 4)
1128 .iter()
1129 .any(|c| c.split('\t').next() == Some(name))
1130 };
1131
1132 tree.min_stability = Stability::Preview;
1134 assert!(has(&tree, "stable-cmd"));
1135 assert!(has(&tree, "preview-cmd"));
1136 assert!(!has(&tree, "exp-cmd"), "experimental hidden at the default threshold");
1137
1138 tree.min_stability = Stability::Experimental;
1140 assert!(has(&tree, "exp-cmd"), "experimental shown when threshold is lowered");
1141
1142 tree.min_stability = Stability::Stable;
1144 assert!(has(&tree, "stable-cmd"));
1145 assert!(!has(&tree, "preview-cmd"), "preview hidden at the stable threshold");
1146 }
1147
1148 #[test]
1149 fn positional_provider_completes_by_command_path() {
1150 use crate::{CommandSpec, PositionalSpec, ValueProvider};
1151 let spec = CommandSpec::new("app").subcommand(
1153 CommandSpec::new("backends")
1154 .subcommand(CommandSpec::new("remove").positional(PositionalSpec::new("name")))
1155 .subcommand(CommandSpec::new("list")),
1156 );
1157 let provider: ValueProvider = std::sync::Arc::new(|partial: &str, _: &[&str]| {
1158 ["store", "archive"]
1159 .iter()
1160 .filter(|s| s.starts_with(partial))
1161 .map(|s| s.to_string())
1162 .collect()
1163 });
1164 let mut resolvers = std::collections::BTreeMap::new();
1166 resolvers.insert("backends remove".to_string(), provider);
1167 let tree = crate::cli::build_completion_tree(&spec, &resolvers);
1168
1169 let all = crate::complete(&tree, &["app", "backends", "remove", ""]);
1171 assert!(all.contains(&"store".to_string()) && all.contains(&"archive".to_string()), "{all:?}");
1172
1173 let pref = crate::complete(&tree, &["app", "backends", "remove", "st"]);
1175 assert!(pref.contains(&"store".to_string()) && !pref.contains(&"archive".to_string()), "{pref:?}");
1176
1177 let other = crate::complete(&tree, &["app", "backends", "list", ""]);
1179 assert!(!other.contains(&"store".to_string()), "sibling must not complete: {other:?}");
1180 }
1181
1182 #[test]
1183 fn parse_skips_triple_dash_engine_tokens() {
1184 use crate::{CommandSpec, OptionDef, OptionSpec};
1185 let spec = CommandSpec::new("app").subcommand(
1188 CommandSpec::new("go").option(OptionSpec::new(OptionDef::flag("--verbose"))),
1189 );
1190 let p = crate::cli::parse(&spec, &argv(&["---experimental", "go", "--verbose"])).unwrap();
1191 let (sub, sp) = p.subcommand().unwrap();
1192 assert_eq!(sub, "go");
1193 assert!(sp.has_flag("--verbose"));
1194 }
1195
1196 #[test]
1197 fn stability_prefix_sets_threshold_and_strips_meta() {
1198 use crate::Stability;
1199 let (t, words) = crate::split_stability_prefix(
1200 vec!["---experimental".into(), "datasets".into()],
1201 Stability::Preview,
1202 );
1203 assert_eq!(t, Stability::Experimental);
1204 assert_eq!(words, vec!["datasets".to_string()]);
1205
1206 let (t2, w2) = crate::split_stability_prefix(vec!["x".into()], Stability::Preview);
1208 assert_eq!(t2, Stability::Preview);
1209 assert_eq!(w2, vec!["x".to_string()]);
1210 }
1211}