use std::collections::{BTreeMap, HashSet};
use crate::OptionDef;
#[derive(Clone)]
pub struct OptionSpec {
pub def: OptionDef,
pub required: bool,
pub default: Option<String>,
pub value_completion: Option<crate::ValueProvider>,
}
impl std::fmt::Debug for OptionSpec {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OptionSpec")
.field("def", &self.def)
.field("required", &self.required)
.field("default", &self.default)
.field("value_completion", &self.value_completion.as_ref().map(|_| "<provider>"))
.finish()
}
}
impl OptionSpec {
pub fn new(def: OptionDef) -> Self {
OptionSpec { def, required: false, default: None, value_completion: None }
}
pub fn required(mut self, yes: bool) -> Self {
self.required = yes;
self
}
pub fn default(mut self, v: impl Into<String>) -> Self {
self.default = Some(v.into());
self
}
pub fn value_completion(mut self, provider: crate::ValueProvider) -> Self {
self.value_completion = Some(provider);
self
}
pub fn flag(&self) -> &str {
&self.def.name
}
}
#[derive(Clone, Debug)]
pub struct PositionalSpec {
pub name: String,
pub required: bool,
pub multiple: bool,
pub help: Option<String>,
}
impl PositionalSpec {
pub fn new(name: impl Into<String>) -> Self {
PositionalSpec { name: name.into(), required: false, multiple: false, help: None }
}
pub fn required(mut self, yes: bool) -> Self {
self.required = yes;
self
}
pub fn multiple(mut self, yes: bool) -> Self {
self.multiple = yes;
self
}
pub fn help(mut self, h: impl Into<String>) -> Self {
self.help = Some(h.into());
self
}
}
#[derive(Clone, Debug, Default)]
pub struct CommandSpec {
pub name: String,
pub about: Option<String>,
pub aliases: Vec<String>,
pub options: Vec<OptionSpec>,
pub positionals: Vec<PositionalSpec>,
pub subcommands: Vec<CommandSpec>,
pub subcommand_required: bool,
pub after_help: Option<String>,
pub stability: crate::Stability,
}
impl CommandSpec {
pub fn new(name: impl Into<String>) -> Self {
CommandSpec { name: name.into(), ..Default::default() }
}
pub fn about(mut self, a: impl Into<String>) -> Self {
self.about = Some(a.into());
self
}
pub fn alias(mut self, a: impl Into<String>) -> Self {
self.aliases.push(a.into());
self
}
pub fn after_help(mut self, a: impl Into<String>) -> Self {
self.after_help = Some(a.into());
self
}
pub fn stability(mut self, s: crate::Stability) -> Self {
self.stability = s;
self
}
pub fn option(mut self, o: OptionSpec) -> Self {
self.options.push(o);
self
}
pub fn positional(mut self, p: PositionalSpec) -> Self {
self.positionals.push(p);
self
}
pub fn subcommand(mut self, c: CommandSpec) -> Self {
self.subcommands.push(c);
self
}
fn find_long(&self, token: &str) -> Option<&OptionSpec> {
let want = token.trim_start_matches('-');
self.options.iter().find(|o| o.def.name.trim_start_matches('-') == want)
}
fn find_short(&self, c: char) -> Option<&OptionSpec> {
self.options.iter().find(|o| o.def.short == Some(c))
}
fn find_subcommand(&self, name: &str) -> Option<&CommandSpec> {
self.subcommands
.iter()
.find(|s| s.name == name || s.aliases.iter().any(|a| a == name))
}
}
#[derive(Clone, Debug, Default)]
pub struct ParsedArgs {
flags: HashSet<String>,
values: BTreeMap<String, Vec<String>>,
positionals: Vec<String>,
subcommand: Option<(String, Box<ParsedArgs>)>,
}
impl ParsedArgs {
pub fn has_flag(&self, name: &str) -> bool {
self.flags.contains(name.trim_start_matches('-'))
}
pub fn value(&self, name: &str) -> Option<&str> {
self.values.get(name.trim_start_matches('-')).and_then(|v| v.first()).map(|s| s.as_str())
}
pub fn values(&self, name: &str) -> &[String] {
const EMPTY: &[String] = &[];
self.values.get(name.trim_start_matches('-')).map(|v| v.as_slice()).unwrap_or(EMPTY)
}
pub fn positionals(&self) -> &[String] {
&self.positionals
}
pub fn subcommand(&self) -> Option<(&str, &ParsedArgs)> {
self.subcommand.as_ref().map(|(n, p)| (n.as_str(), p.as_ref()))
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ParseError {
UnknownFlag { command: String, flag: String },
MissingValue { command: String, flag: String },
MissingRequiredOption { command: String, flag: String },
MissingRequiredPositional { command: String, name: String },
UnexpectedPositional { command: String, value: String },
UnknownSubcommand { command: String, name: String },
MissingSubcommand { command: String },
InvalidValue { flag: String, value: String, message: String },
}
pub trait VeksCli: Sized {
fn veks_command_spec(name: &str) -> CommandSpec;
fn veks_augment_spec(spec: CommandSpec) -> CommandSpec;
fn veks_from_parsed(parsed: &ParsedArgs) -> Result<Self, ParseError>;
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ParseError::UnknownFlag { command, flag } =>
write!(f, "{command}: unexpected option '{flag}'"),
ParseError::MissingValue { command, flag } =>
write!(f, "{command}: option '{flag}' requires a value"),
ParseError::MissingRequiredOption { command, flag } =>
write!(f, "{command}: required option '{flag}' not provided"),
ParseError::MissingRequiredPositional { command, name } =>
write!(f, "{command}: required argument <{name}> not provided"),
ParseError::UnexpectedPositional { command, value } =>
write!(f, "{command}: unexpected argument '{value}'"),
ParseError::UnknownSubcommand { command, name } =>
write!(f, "{command}: unknown subcommand '{name}'"),
ParseError::MissingSubcommand { command } =>
write!(f, "{command}: a subcommand is required"),
ParseError::InvalidValue { flag, value, message } =>
write!(f, "invalid value '{value}' for '{flag}': {message}"),
}
}
}
impl std::error::Error for ParseError {}
pub fn parse(spec: &CommandSpec, argv: &[String]) -> Result<ParsedArgs, ParseError> {
let mut out = ParsedArgs::default();
let mut i = 0;
let mut options_ended = false;
while i < argv.len() {
let arg = &argv[i];
if !options_ended && arg == "--" {
options_ended = true;
i += 1;
continue;
}
if !options_ended && arg.starts_with("---") {
i += 1;
continue;
}
if !options_ended && arg.starts_with("--") {
let body = &arg[2..];
let (name, inline) = match body.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (body, None),
};
let opt = spec
.find_long(name)
.ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
let canon = opt.def.name.trim_start_matches('-').to_string();
if !opt.def.takes_value {
out.flags.insert(canon);
} else {
let value = match inline {
Some(v) => v,
None => {
i += 1;
argv.get(i)
.cloned()
.ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
}
};
out.values.entry(canon).or_default().push(value);
}
i += 1;
continue;
}
if !options_ended && arg.starts_with('-') && arg.len() > 1 {
let body = &arg[1..];
let mut chars = body.chars();
let short = chars.next().unwrap();
let rest: String = chars.collect();
let opt = spec
.find_short(short)
.ok_or_else(|| ParseError::UnknownFlag { command: spec.name.clone(), flag: arg.clone() })?;
let canon = opt.def.name.trim_start_matches('-').to_string();
if !opt.def.takes_value {
out.flags.insert(canon);
} else {
let value = if let Some(stripped) = rest.strip_prefix('=') {
stripped.to_string()
} else if !rest.is_empty() {
rest
} else {
i += 1;
argv.get(i)
.cloned()
.ok_or_else(|| ParseError::MissingValue { command: spec.name.clone(), flag: arg.clone() })?
};
out.values.entry(canon).or_default().push(value);
}
i += 1;
continue;
}
if !spec.subcommands.is_empty() && out.positionals.is_empty() {
let sub = spec.find_subcommand(arg).ok_or_else(|| ParseError::UnknownSubcommand {
command: spec.name.clone(),
name: arg.clone(),
})?;
let sub_parsed = parse(sub, &argv[i + 1..])?;
out.subcommand = Some((sub.name.clone(), Box::new(sub_parsed)));
finalize(spec, &mut out)?;
return Ok(out);
}
out.positionals.push(arg.clone());
i += 1;
}
finalize(spec, &mut out)?;
Ok(out)
}
fn finalize(spec: &CommandSpec, out: &mut ParsedArgs) -> Result<(), ParseError> {
for opt in &spec.options {
let canon = opt.def.name.trim_start_matches('-').to_string();
let present = out.flags.contains(&canon) || out.values.contains_key(&canon);
if !present {
if let Some(def) = &opt.default {
out.values.entry(canon.clone()).or_default().push(def.clone());
} else if opt.required {
return Err(ParseError::MissingRequiredOption {
command: spec.name.clone(),
flag: opt.def.name.clone(),
});
}
}
}
let required_positionals = spec.positionals.iter().filter(|p| p.required).count();
if out.positionals.len() < required_positionals {
let missing = &spec.positionals[out.positionals.len()];
return Err(ParseError::MissingRequiredPositional {
command: spec.name.clone(),
name: missing.name.clone(),
});
}
if spec.subcommand_required && out.subcommand.is_none() {
return Err(ParseError::MissingSubcommand { command: spec.name.clone() });
}
Ok(())
}
const HELP_WIDTH: usize = 100;
fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
for para in text.split('\n') {
if para.trim().is_empty() {
lines.push(String::new());
continue;
}
let mut cur = String::new();
for word in para.split_whitespace() {
if cur.is_empty() {
cur.push_str(word);
} else if width == 0 || cur.len() + 1 + word.len() <= width {
cur.push(' ');
cur.push_str(word);
} else {
lines.push(std::mem::take(&mut cur));
cur.push_str(word);
}
}
lines.push(cur);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
fn render_two_col(out: &mut String, rows: &[(String, String)]) {
if rows.is_empty() {
return;
}
let col = rows.iter().map(|(l, _)| l.len()).max().unwrap_or(0).min(28);
let help_width = HELP_WIDTH.saturating_sub(col + 4).max(20);
for (left, help) in rows {
let wrapped = wrap_text(help, help_width);
let mut iter = wrapped.iter();
let first = iter.next().map(|s| s.as_str()).unwrap_or("");
if left.len() <= col {
out.push_str(&format!(" {:<col$} {}\n", left, first, col = col));
} else {
out.push_str(&format!(" {}\n", left));
out.push_str(&format!(" {:<col$} {}\n", "", first, col = col));
}
for cont in iter {
out.push_str(&format!(" {:<col$} {}\n", "", cont, col = col));
}
}
}
pub fn render_help_for<S: AsRef<str>>(root: &CommandSpec, argv: &[S]) -> String {
let mut spec = root;
for word in argv {
let word = word.as_ref();
if word.starts_with('-') {
break;
}
match spec.find_subcommand(word) {
Some(sub) => spec = sub,
None => break,
}
}
render_help(spec)
}
pub fn render_help(spec: &CommandSpec) -> String {
let mut s = String::new();
if let Some(about) = &spec.about {
for line in wrap_text(about, HELP_WIDTH) {
s.push_str(&line);
s.push('\n');
}
s.push('\n');
}
s.push_str(&format!("Usage: {}", spec.name));
if !spec.options.is_empty() {
s.push_str(" [OPTIONS]");
}
for p in &spec.positionals {
let token = if p.multiple {
format!("[{}]...", p.name)
} else if p.required {
format!("<{}>", p.name)
} else {
format!("[{}]", p.name)
};
s.push(' ');
s.push_str(&token);
}
if !spec.subcommands.is_empty() {
s.push_str(" <COMMAND>");
}
s.push('\n');
if !spec.aliases.is_empty() {
s.push_str(&format!("\nAliases: {}\n", spec.aliases.join(", ")));
}
if !spec.subcommands.is_empty() {
s.push_str("\nCommands:\n");
let rows: Vec<(String, String)> = spec
.subcommands
.iter()
.map(|c| {
let name = if c.aliases.is_empty() {
c.name.clone()
} else {
format!("{}, {}", c.name, c.aliases.join(", "))
};
(name, c.about.clone().unwrap_or_default())
})
.collect();
render_two_col(&mut s, &rows);
}
if !spec.positionals.is_empty() {
s.push_str("\nArguments:\n");
let rows: Vec<(String, String)> = spec
.positionals
.iter()
.map(|p| (format!("<{}>", p.name), p.help.clone().unwrap_or_default()))
.collect();
render_two_col(&mut s, &rows);
}
{
s.push_str("\nOptions:\n");
let mut rows: Vec<(String, String)> = spec
.options
.iter()
.map(|o| {
let mut f = match o.def.short {
Some(sh) => format!("-{}, ", sh),
None => " ".to_string(),
};
f.push_str(&o.def.name);
if o.def.takes_value {
f.push_str(&format!(" <{}>", o.def.value_name.as_deref().unwrap_or("VALUE")));
}
(f, o.def.help.clone().unwrap_or_default())
})
.collect();
rows.push(("-h, --help".to_string(), "Print help".to_string()));
render_two_col(&mut s, &rows);
}
if let Some(after) = &spec.after_help {
s.push('\n');
s.push_str(after.trim_end());
s.push('\n');
}
s
}
pub fn build_completion_tree(
spec: &CommandSpec,
resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
) -> crate::CommandTree {
let mut tree = crate::CommandTree::new(&spec.name);
tree.root = spec_to_node(spec, resolvers, "");
tree
}
fn spec_to_node(
spec: &CommandSpec,
resolvers: &std::collections::BTreeMap<String, crate::ValueProvider>,
path: &str,
) -> crate::Node {
if spec.subcommands.is_empty() {
let value_flags: Vec<&str> =
spec.options.iter().filter(|o| o.def.takes_value).map(|o| o.def.name.as_str()).collect();
let boolean_flags: Vec<&str> =
spec.options.iter().filter(|o| !o.def.takes_value).map(|o| o.def.name.as_str()).collect();
let mut node = crate::Node::leaf_with_flags(&value_flags, &boolean_flags);
for o in &spec.options {
if let Some(h) = &o.def.help {
node = node.with_flag_help(&o.def.name, h);
}
if o.def.takes_value {
let provider = o
.value_completion
.clone()
.or_else(|| resolvers.get(&o.def.name).cloned());
if let Some(p) = provider {
node = node.with_value_provider(&o.def.name, p);
}
}
}
if !spec.positionals.is_empty() {
if let Some(p) = resolvers.get(path).cloned() {
node = node.with_positional_provider(p);
}
}
node.with_stability(spec.stability)
} else {
let mut node = crate::Node::empty_group();
for sub in &spec.subcommands {
let child_path =
if path.is_empty() { sub.name.clone() } else { format!("{path} {}", sub.name) };
node = node.with_child(&sub.name, spec_to_node(sub, resolvers, &child_path));
}
node.with_stability(spec.stability)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vopt(name: &str) -> OptionSpec {
OptionSpec::new(OptionDef::value(name))
}
fn fopt(name: &str) -> OptionSpec {
OptionSpec::new(OptionDef::flag(name))
}
fn datasets_ping() -> CommandSpec {
CommandSpec::new("ping")
.about("Ping a remote dataset")
.option(vopt("--at").def_multiple())
.option(OptionSpec::new(OptionDef::value("--dataset")).required(true))
.option(OptionSpec::new(OptionDef::value("--profile")).default("default"))
}
impl OptionSpec {
fn def_multiple(mut self) -> Self {
self.def = self.def.multiple(true);
self
}
}
fn argv(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[test]
fn parses_value_space_and_equals_forms() {
let spec = datasets_ping();
let p = parse(&spec, &argv(&["--dataset", "glove", "--at", "1"])).unwrap();
assert_eq!(p.value("--dataset"), Some("glove"));
assert_eq!(p.values("--at"), &["1".to_string()]);
let p2 = parse(&spec, &argv(&["--dataset=glove"])).unwrap();
assert_eq!(p2.value("--dataset"), Some("glove"));
}
#[test]
fn repeatable_option_accumulates() {
let spec = datasets_ping();
let p = parse(&spec, &argv(&["--dataset", "d", "--at", "1", "--at", "2"])).unwrap();
assert_eq!(p.values("--at"), &["1".to_string(), "2".to_string()]);
}
#[test]
fn default_applies_when_absent() {
let spec = datasets_ping();
let p = parse(&spec, &argv(&["--dataset", "d"])).unwrap();
assert_eq!(p.value("--profile"), Some("default"));
}
#[test]
fn required_option_missing_errors() {
let spec = datasets_ping();
let err = parse(&spec, &argv(&["--at", "1"])).unwrap_err();
assert_eq!(err, ParseError::MissingRequiredOption { command: "ping".into(), flag: "--dataset".into() });
}
#[test]
fn unknown_flag_errors() {
let spec = datasets_ping();
let err = parse(&spec, &argv(&["--dataset", "d", "--nope"])).unwrap_err();
assert!(matches!(err, ParseError::UnknownFlag { .. }));
}
#[test]
fn boolean_flag_takes_no_value() {
let spec = CommandSpec::new("list").option(fopt("--verbose"));
let p = parse(&spec, &argv(&["--verbose"])).unwrap();
assert!(p.has_flag("--verbose"));
let p2 = parse(&spec, &argv(&["--verbose", "x"])).unwrap();
assert_eq!(p2.positionals(), &["x".to_string()]);
}
#[test]
fn double_dash_ends_options() {
let spec = CommandSpec::new("run").option(fopt("--flag"));
let p = parse(&spec, &argv(&["--", "--flag"])).unwrap();
assert!(!p.has_flag("--flag"));
assert_eq!(p.positionals(), &["--flag".to_string()]);
}
#[test]
fn subcommand_dispatch_and_short_value() {
let spec = CommandSpec::new("datasets")
.subcommand(datasets_ping())
.subcommand(
CommandSpec::new("derive")
.option(OptionSpec::new(OptionDef::value("--output").short('o')).required(true)),
);
let p = parse(&spec, &argv(&["derive", "-o", "/tmp/out"])).unwrap();
let (name, sub) = p.subcommand().unwrap();
assert_eq!(name, "derive");
assert_eq!(sub.value("--output"), Some("/tmp/out"));
}
#[test]
fn unknown_subcommand_errors() {
let spec = CommandSpec::new("datasets").subcommand(datasets_ping());
let err = parse(&spec, &argv(&["frobnicate"])).unwrap_err();
assert!(matches!(err, ParseError::UnknownSubcommand { .. }));
}
#[test]
fn completion_tree_built_from_spec() {
let spec = CommandSpec::new("veks").subcommand(
CommandSpec::new("datasets")
.subcommand(
CommandSpec::new("ping")
.option(vopt("--at").def_multiple())
.option(vopt("--dataset")),
)
.subcommand(CommandSpec::new("list").option(fopt("--verbose"))),
);
let mut resolvers: std::collections::BTreeMap<String, crate::ValueProvider> =
std::collections::BTreeMap::new();
resolvers.insert(
"--at".to_string(),
crate::fn_provider(|_p, _c| vec!["1".to_string(), "2".to_string()]),
);
let tree = build_completion_tree(&spec, &resolvers);
let ping_flags = crate::complete(&tree, &["veks", "datasets", "ping", "--"]);
assert!(ping_flags.contains(&"--at".to_string()));
assert!(ping_flags.contains(&"--dataset".to_string()));
let list_flags = crate::complete(&tree, &["veks", "datasets", "list", "--"]);
assert!(list_flags.contains(&"--verbose".to_string()));
assert!(!list_flags.contains(&"--at".to_string()), "--at must not leak onto list");
let at_vals = crate::complete(&tree, &["veks", "datasets", "ping", "--at", ""]);
assert_eq!(at_vals, vec!["1".to_string(), "2".to_string()]);
}
}
#[cfg(test)]
mod derive_tests {
use crate::VeksCli;
use veks_completion_derive::VeksCli;
fn argv(s: &[&str]) -> Vec<String> {
s.iter().map(|x| x.to_string()).collect()
}
#[derive(VeksCli, Debug, PartialEq)]
#[command(about = "Ping a remote dataset")]
struct Ping {
#[arg(long = "at")]
at: Vec<String>,
#[arg(long)]
dataset: String,
#[arg(long, default = "default")]
profile: String,
#[arg(long)]
verbose: bool,
}
#[test]
fn derive_struct_spec_and_extract() {
let spec = Ping::veks_command_spec("ping");
assert_eq!(spec.about.as_deref(), Some("Ping a remote dataset"));
let p = crate::cli::parse(
&spec,
&argv(&["--dataset", "glove", "--at", "1", "--at", "2", "--verbose"]),
)
.unwrap();
let ping = Ping::veks_from_parsed(&p).unwrap();
assert_eq!(
ping,
Ping {
at: vec!["1".into(), "2".into()],
dataset: "glove".into(),
profile: "default".into(),
verbose: true,
}
);
}
#[test]
fn derive_typed_conversion_and_default() {
#[derive(VeksCli, Debug, PartialEq)]
struct Run {
#[arg(long, default = "4")]
threads: usize,
#[arg(long)]
tag: Option<String>,
}
let spec = Run::veks_command_spec("run");
let p = crate::cli::parse(&spec, &argv(&["--threads", "8"])).unwrap();
let run = Run::veks_from_parsed(&p).unwrap();
assert_eq!(run, Run { threads: 8, tag: None });
let p2 = crate::cli::parse(&spec, &argv(&[])).unwrap();
assert_eq!(Run::veks_from_parsed(&p2).unwrap().threads, 4);
let p3 = crate::cli::parse(&spec, &argv(&["--threads", "abc"])).unwrap();
assert!(matches!(
Run::veks_from_parsed(&p3),
Err(crate::cli::ParseError::InvalidValue { .. })
));
}
#[derive(VeksCli, Debug, PartialEq)]
enum Cmd {
Ping(Ping),
List {
#[arg(long)]
verbose: bool,
},
}
#[derive(VeksCli)]
#[command(stability = "preview")]
struct PreviewArgs {
#[arg(long)]
x: bool,
}
#[derive(VeksCli)]
enum StabilityCmd {
Steady {
#[arg(long)]
a: bool,
},
#[command(stability = "experimental")]
Risky {
#[arg(long)]
b: bool,
},
}
#[test]
fn derive_reads_command_stability() {
use crate::Stability;
assert_eq!(
PreviewArgs::veks_command_spec("preview-args").stability,
Stability::Preview
);
let spec = StabilityCmd::veks_command_spec("app");
let steady = spec.subcommands.iter().find(|c| c.name == "steady").unwrap();
let risky = spec.subcommands.iter().find(|c| c.name == "risky").unwrap();
assert_eq!(steady.stability, Stability::Stable);
assert_eq!(risky.stability, Stability::Experimental);
}
#[test]
fn derive_enum_subcommand_dispatch() {
let spec = Cmd::veks_command_spec("veks");
assert!(spec.subcommand_required);
let p = crate::cli::parse(&spec, &argv(&["ping", "--dataset", "d"])).unwrap();
match Cmd::veks_from_parsed(&p).unwrap() {
Cmd::Ping(ping) => assert_eq!(ping.dataset, "d"),
_ => panic!("expected Ping"),
}
let p2 = crate::cli::parse(&spec, &argv(&["list", "--verbose"])).unwrap();
assert_eq!(Cmd::veks_from_parsed(&p2).unwrap(), Cmd::List { verbose: true });
}
#[test]
fn completion_hides_commands_below_stability_threshold() {
use crate::{CommandSpec, Stability};
let spec = CommandSpec::new("app")
.subcommand(CommandSpec::new("stable-cmd"))
.subcommand(CommandSpec::new("preview-cmd").stability(Stability::Preview))
.subcommand(CommandSpec::new("exp-cmd").stability(Stability::Experimental));
let resolvers = std::collections::BTreeMap::new();
let mut tree = crate::cli::build_completion_tree(&spec, &resolvers);
let has = |t: &crate::CommandTree, name: &str| {
crate::complete_at_tap_with_raw(t, &["app", ""], 1, "app ", 4)
.iter()
.any(|c| c.split('\t').next() == Some(name))
};
tree.min_stability = Stability::Preview;
assert!(has(&tree, "stable-cmd"));
assert!(has(&tree, "preview-cmd"));
assert!(!has(&tree, "exp-cmd"), "experimental hidden at the default threshold");
tree.min_stability = Stability::Experimental;
assert!(has(&tree, "exp-cmd"), "experimental shown when threshold is lowered");
tree.min_stability = Stability::Stable;
assert!(has(&tree, "stable-cmd"));
assert!(!has(&tree, "preview-cmd"), "preview hidden at the stable threshold");
}
#[test]
fn positional_provider_completes_by_command_path() {
use crate::{CommandSpec, PositionalSpec, ValueProvider};
let spec = CommandSpec::new("app").subcommand(
CommandSpec::new("backends")
.subcommand(CommandSpec::new("remove").positional(PositionalSpec::new("name")))
.subcommand(CommandSpec::new("list")),
);
let provider: ValueProvider = std::sync::Arc::new(|partial: &str, _: &[&str]| {
["store", "archive"]
.iter()
.filter(|s| s.starts_with(partial))
.map(|s| s.to_string())
.collect()
});
let mut resolvers = std::collections::BTreeMap::new();
resolvers.insert("backends remove".to_string(), provider);
let tree = crate::cli::build_completion_tree(&spec, &resolvers);
let all = crate::complete(&tree, &["app", "backends", "remove", ""]);
assert!(all.contains(&"store".to_string()) && all.contains(&"archive".to_string()), "{all:?}");
let pref = crate::complete(&tree, &["app", "backends", "remove", "st"]);
assert!(pref.contains(&"store".to_string()) && !pref.contains(&"archive".to_string()), "{pref:?}");
let other = crate::complete(&tree, &["app", "backends", "list", ""]);
assert!(!other.contains(&"store".to_string()), "sibling must not complete: {other:?}");
}
#[test]
fn parse_skips_triple_dash_engine_tokens() {
use crate::{CommandSpec, OptionDef, OptionSpec};
let spec = CommandSpec::new("app").subcommand(
CommandSpec::new("go").option(OptionSpec::new(OptionDef::flag("--verbose"))),
);
let p = crate::cli::parse(&spec, &argv(&["---experimental", "go", "--verbose"])).unwrap();
let (sub, sp) = p.subcommand().unwrap();
assert_eq!(sub, "go");
assert!(sp.has_flag("--verbose"));
}
#[test]
fn stability_prefix_sets_threshold_and_strips_meta() {
use crate::Stability;
let (t, words) = crate::split_stability_prefix(
vec!["---experimental".into(), "datasets".into()],
Stability::Preview,
);
assert_eq!(t, Stability::Experimental);
assert_eq!(words, vec!["datasets".to_string()]);
let (t2, w2) = crate::split_stability_prefix(vec!["x".into()], Stability::Preview);
assert_eq!(t2, Stability::Preview);
assert_eq!(w2, vec!["x".to_string()]);
}
}