use heck::ToSnakeCase;
use indexmap::IndexMap;
use itertools::Itertools;
use log::trace;
use miette::bail;
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::fmt::{Debug, Display, Formatter};
use std::sync::Arc;
use strum::EnumTryAs;
#[cfg(feature = "docs")]
use crate::docs;
use crate::error::UsageErr;
use crate::spec::arg::SpecDoubleDashChoices;
use crate::spec::unknown_flags::UnknownFlags;
use crate::warn::Warning;
use crate::{Spec, SpecArg, SpecChoices, SpecCommand, SpecFlag};
fn merge_subcommand_flags(
available: &mut BTreeMap<String, Arc<SpecFlag>>,
new_flags: BTreeMap<String, Arc<SpecFlag>>,
crossing_mount: bool,
) {
available.retain(|_, f| f.global);
if crossing_mount {
for (key, flag) in new_flags {
available.insert(key, flag);
}
return;
}
let mut merged_cache: HashMap<usize, Arc<SpecFlag>> = HashMap::new();
let mut merged_origin: HashMap<usize, usize> = HashMap::new();
fn origin_of(merged_origin: &HashMap<usize, usize>, flag: &Arc<SpecFlag>) -> usize {
let ptr = Arc::as_ptr(flag) as usize;
*merged_origin.get(&ptr).unwrap_or(&ptr)
}
for (key, flag) in new_flags {
if flag.global {
available.insert(key, flag);
continue;
}
let inherited_global = flag.long.iter().find_map(|l| {
available
.get(&format!("--{l}"))
.filter(|f| f.global)
.cloned()
});
if let Some(global_flag) = inherited_global {
let global_origin = origin_of(&merged_origin, &global_flag);
if available.get(&key).is_some_and(|existing| {
existing.global && origin_of(&merged_origin, existing) != global_origin
}) {
continue;
}
let merged = match merged_cache.get(&(Arc::as_ptr(&flag) as usize)) {
Some(merged) => merged.clone(),
None => {
let mut merged = (*global_flag).clone();
for s in &flag.short {
if !merged.short.contains(s) {
merged.short.push(*s);
}
}
for l in &flag.long {
if !merged.long.contains(l) {
merged.long.push(l.clone());
}
}
merged.hidden_short_aliases.retain(|alias| {
!flag.short.contains(alias) || flag.hidden_short_aliases.contains(alias)
});
merged.hidden_aliases.retain(|alias| {
!flag.long.contains(alias) || flag.hidden_aliases.contains(alias)
});
for s in &flag.hidden_short_aliases {
if !merged.hidden_short_aliases.contains(s) {
merged.hidden_short_aliases.push(*s);
}
}
for l in &flag.hidden_aliases {
if !merged.hidden_aliases.contains(l) {
merged.hidden_aliases.push(l.clone());
}
}
let merged = Arc::new(merged);
merged_cache.insert(Arc::as_ptr(&flag) as usize, Arc::clone(&merged));
merged_origin.insert(Arc::as_ptr(&merged) as usize, global_origin);
for existing in available.values_mut() {
if origin_of(&merged_origin, existing) == global_origin {
*existing = Arc::clone(&merged);
}
}
merged
}
};
available.insert(key, merged);
continue;
}
if available.contains_key(&key) {
continue;
}
available.insert(key, flag);
}
}
fn flag_keys(flag: &SpecFlag) -> Vec<String> {
let mut keys: Vec<String> = flag
.long
.iter()
.map(|l| format!("--{l}"))
.chain(flag.short.iter().map(|s| format!("-{s}")))
.collect();
if let Some(negate) = &flag.negate {
keys.push(negate.clone());
}
keys
}
fn gather_flags(cmd: &SpecCommand) -> BTreeMap<String, Arc<SpecFlag>> {
cmd.flags
.iter()
.flat_map(|f| {
let f = Arc::new(f.clone()); flag_keys(&f)
.into_iter()
.map(|key| (key, Arc::clone(&f)))
.collect::<Vec<_>>()
})
.collect()
}
fn unique_flags<'a>(
flags: impl IntoIterator<Item = &'a Arc<SpecFlag>>,
) -> impl Iterator<Item = &'a Arc<SpecFlag>> {
let mut seen = HashSet::new();
flags
.into_iter()
.filter(move |flag| seen.insert(Arc::as_ptr(flag) as usize))
}
pub fn available_flags(chain: &[&SpecCommand]) -> Vec<Arc<SpecFlag>> {
let Some((root, rest)) = chain.split_first() else {
return vec![];
};
let mut available = gather_flags(root);
for cmd in rest {
merge_subcommand_flags(&mut available, gather_flags(cmd), false);
}
let mut seen_names = HashSet::new();
unique_flags(available.values())
.filter(|f| seen_names.insert(f.name.clone()))
.cloned()
.collect()
}
fn get_flag_key(word: &str) -> &str {
if word.starts_with("--") {
word.split_once('=').map(|(k, _)| k).unwrap_or(word)
} else if let Some((end, _)) = word.char_indices().nth(2) {
&word[0..end]
} else {
word
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValueOrigin {
DefaultMissing,
Env(String),
Default,
DefaultIf {
selector: String,
when: Option<String>,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum TokenRole {
Program,
Command { name: String },
Flag {
flag: Arc<SpecFlag>,
spelling: String,
negated: bool,
},
Value {
flag: Arc<SpecFlag>,
values: Vec<String>,
attached: bool,
},
Arg {
arg: Arc<SpecArg>,
values: Vec<String>,
},
Separator,
Builtin { spelling: String },
ValueTerminator { ends: String },
Restart,
UnknownFlag { bound_as: Option<Arc<SpecArg>> },
Refused { reason: String },
External,
Unread,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct TokenBinding {
pub index: usize,
pub word: String,
pub synthesized: bool,
pub roles: Vec<TokenRole>,
}
#[non_exhaustive]
pub struct ParseOutput {
pub cmd: SpecCommand,
pub cmds: Vec<SpecCommand>,
pub args: IndexMap<Arc<SpecArg>, ParseValue>,
pub flags: IndexMap<Arc<SpecFlag>, ParseValue>,
pub tokens: Vec<TokenBinding>,
pub flag_origins: IndexMap<Arc<SpecFlag>, Vec<ValueOrigin>>,
pub arg_origins: IndexMap<Arc<SpecArg>, Vec<ValueOrigin>>,
pub overridden_flags: BTreeMap<String, String>,
pub available_flags: BTreeMap<String, Arc<SpecFlag>>,
pub flag_awaiting_value: Vec<Arc<SpecFlag>>,
pub errors: Vec<UsageErr>,
pub warnings: Vec<Warning>,
pub next_arg: Option<Arc<SpecArg>>,
pub double_dash_seen: bool,
pub external: Option<Vec<String>>,
}
impl ParseOutput {
pub fn completion_flags(&self) -> BTreeMap<String, Arc<SpecFlag>> {
let Some(boundary) = self.cmds.iter().position(|cmd| cmd.mounted) else {
return self.available_flags.clone();
};
let start = match boundary.checked_sub(1) {
Some(prev) if self.cmds[prev].flags_from_mount => prev,
_ => boundary,
};
let mut offered = gather_flags(&self.cmds[start]);
for cmd in &self.cmds[start + 1..] {
merge_subcommand_flags(&mut offered, gather_flags(cmd), false);
}
offered
}
}
#[derive(Debug, EnumTryAs, Clone)]
pub enum ParseValue {
Bool(bool),
String(String),
MultiBool(Vec<bool>),
MultiString(Vec<String>),
}
fn collect_deprecations(out: &mut ParseOutput) {
for cmd in out.cmds.iter().skip(1) {
if cmd.deprecated.is_none()
&& cmd.deprecated_warn_at.is_none()
&& cmd.deprecated_remove_at.is_none()
{
continue;
}
out.warnings.push(Warning::command(
cmd.name.clone(),
cmd.deprecated.clone(),
cmd.deprecated_warn_at.clone(),
cmd.deprecated_remove_at.clone(),
));
}
for flag in out.flags.keys() {
if let Some(warning) = flag_deprecation(flag) {
out.warnings.push(warning);
}
}
}
fn flag_deprecation(flag: &SpecFlag) -> Option<Warning> {
if flag.deprecated.is_none()
&& flag.deprecated_warn_at.is_none()
&& flag.deprecated_remove_at.is_none()
{
return None;
}
Some(Warning::flag(
flag_spelling(flag),
flag.deprecated.clone(),
flag.deprecated_warn_at.clone(),
flag.deprecated_remove_at.clone(),
))
}
fn flag_spelling(flag: &SpecFlag) -> String {
flag.long
.first()
.map(|long| format!("--{long}"))
.or_else(|| flag.short.first().map(|short| format!("-{short}")))
.unwrap_or_else(|| flag.name.clone())
}
fn flag_current_env(flag: &SpecFlag) -> Option<String> {
flag.env
.clone()
.or_else(|| flag.env_fallback.first().cloned())
}
fn flag_env_is_deprecated(flag: &SpecFlag, name: &str) -> bool {
flag.deprecated_env.iter().any(|declared| declared == name)
}
fn arg_current_env(arg: &SpecArg) -> Option<String> {
arg.env
.clone()
.or_else(|| arg.env_fallback.first().cloned())
}
fn arg_env_is_deprecated(arg: &SpecArg, name: &str) -> bool {
arg.deprecated_env.iter().any(|declared| declared == name)
}
fn first_set_env<'a>(
mut names: impl Iterator<Item = &'a str>,
get_env: &impl Fn(&str) -> Option<String>,
) -> Option<(&'a str, String)> {
names.find_map(|name| get_env(name).map(|value| (name, value)))
}
#[non_exhaustive]
pub struct Parser<'a> {
spec: &'a Spec,
env: Option<HashMap<String, String>>,
mount_outputs: Option<HashMap<String, String>>,
}
impl<'a> Parser<'a> {
pub fn new(spec: &'a Spec) -> Self {
Self {
spec,
env: None,
mount_outputs: None,
}
}
pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
self.env = Some(env);
self
}
pub fn with_mount_outputs(mut self, outputs: HashMap<String, String>) -> Self {
self.mount_outputs = Some(outputs);
self
}
pub fn parse(self, input: &[String]) -> Result<ParseOutput, miette::Error> {
let out = self.parse_collecting(input)?;
if let Some(err) = out
.errors
.iter()
.find(|e| matches!(e, UsageErr::Help(_) | UsageErr::Version(_)))
{
bail!("{err}");
}
if !out.errors.is_empty() {
bail!("{}", out.errors.iter().map(|e| e.to_string()).join("\n"));
}
Ok(out)
}
pub fn explain(self, input: &[String]) -> Result<ParseOutput, miette::Error> {
self.parse_collecting(input)
}
pub fn explain_refused(self, input: &[String]) -> Result<ParseOutput, Vec<TokenBinding>> {
let mut trace = Trace::new(input);
match parse_partial_traced(
self.spec,
input,
self.env.as_ref(),
self.mount_outputs.as_ref(),
MountTiming::WhenAWordIsUnknown,
&mut trace,
) {
Ok((out, _)) => Ok(out),
Err(_) => Err(trace.tokens),
}
}
fn parse_collecting(self, input: &[String]) -> Result<ParseOutput, miette::Error> {
let custom_env = self.env.as_ref();
let (mut out, overridden_flags) = parse_partial_with_env(
self.spec,
input,
custom_env,
self.mount_outputs.as_ref(),
MountTiming::WhenAWordIsUnknown,
)?;
trace!("{out:?}");
while try_bind_default_missing(
&mut out.flags,
&mut out.flag_awaiting_value,
custom_env,
&mut out.flag_origins,
)? {}
if let Some(flag) = out.flag_awaiting_value.first() {
let token = flag
.long
.first()
.map(|l| format!("--{l}"))
.or_else(|| flag.short.first().map(|s| format!("-{s}")))
.unwrap_or_else(|| flag.name.clone());
let rendered = input.join(" ");
let span = rendered
.rfind(&token)
.map(|at| (at, token.len()))
.unwrap_or((0, 0));
return Err(UsageErr::InvalidFlag {
token,
reason: "requires an argument".to_string(),
span: span.into(),
input: rendered,
}
.into());
}
collect_deprecations(&mut out);
let get_env = |key: &str| -> Option<String> {
if let Some(env_map) = custom_env {
env_map.get(key).cloned()
} else {
std::env::var(key).ok()
}
};
for arg in out.cmd.args.iter() {
if out.args.contains_key(arg) {
continue;
}
if let Some((env_name, env_value)) = first_set_env(arg.env_names(), &get_env) {
if arg_env_is_deprecated(arg, env_name) {
out.warnings
.push(Warning::env(env_name, arg_current_env(arg)));
}
let values = split_fallback_values(std::slice::from_ref(&env_value), arg.delimiter);
validate_choice_values(
ChoiceTarget::arg(arg),
&values,
arg.choices.as_ref(),
custom_env,
)?;
let parsed = if arg.var {
validate_arg_fallback_count(arg, values.len(), &mut out.errors);
ParseValue::MultiString(values)
} else {
ParseValue::String(values.into_iter().next().unwrap_or_default())
};
out.args.insert(Arc::new(arg.clone()), parsed);
out.arg_origins
.entry(Arc::new(arg.clone()))
.or_default()
.push(ValueOrigin::Env(env_name.to_string()));
continue;
}
if !arg.default.is_empty() {
if arg.var {
let values = split_fallback_values(&arg.default, arg.delimiter);
validate_arg_fallback_count(arg, values.len(), &mut out.errors);
validate_choice_values(
ChoiceTarget::arg(arg),
&values,
arg.choices.as_ref(),
custom_env,
)?;
out.args
.insert(Arc::new(arg.clone()), ParseValue::MultiString(values));
out.arg_origins
.entry(Arc::new(arg.clone()))
.or_default()
.push(ValueOrigin::Default);
} else {
validate_choice_value(
ChoiceTarget::arg(arg),
&arg.default[0],
arg.choices.as_ref(),
custom_env,
)?;
out.args.insert(
Arc::new(arg.clone()),
ParseValue::String(arg.default[0].clone()),
);
out.arg_origins
.entry(Arc::new(arg.clone()))
.or_default()
.push(ValueOrigin::Default);
}
}
}
let flags: Vec<Arc<SpecFlag>> = out.available_flags.values().cloned().collect();
for flag in &flags {
if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) {
continue;
}
if let Some((env_name, env_value)) = first_set_env(flag.env_names(), &get_env) {
if let Some(warning) = flag_deprecation(flag) {
out.warnings.push(warning);
}
if flag_env_is_deprecated(flag, env_name) {
out.warnings
.push(Warning::env(env_name, flag_current_env(flag)));
}
if let Some(arg) = flag.arg.as_ref() {
let values =
split_fallback_values(std::slice::from_ref(&env_value), arg.delimiter);
validate_choice_values(
ChoiceTarget::option(flag),
&values,
arg.choices.as_ref(),
custom_env,
)?;
let parsed = if flag.var || arg.var {
if flag.var {
validate_flag_fallback_count(flag, values.len(), &mut out.errors);
}
if arg.var {
validate_flag_arg_fallback_count(
flag,
arg,
values.len(),
&mut out.errors,
);
}
ParseValue::MultiString(values)
} else {
ParseValue::String(values.into_iter().next().unwrap_or_default())
};
out.flags.insert(Arc::clone(flag), parsed);
} else {
let is_true = matches!(env_value.as_str(), "1" | "true" | "True" | "TRUE");
out.flags
.insert(Arc::clone(flag), ParseValue::Bool(is_true));
}
out.flag_origins
.entry(Arc::clone(flag))
.or_default()
.push(ValueOrigin::Env(env_name.to_string()));
}
}
let mut from_default_if: Vec<(Arc<SpecFlag>, crate::SpecDefaultIf)> = Vec::new();
for flag in &flags {
if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) {
continue;
}
if let Some(condition) = flag.default_if.iter().find(|condition| {
default_if_condition_matches(condition, &out, &overridden_flags, custom_env)
}) {
from_default_if.push((Arc::clone(flag), condition.clone()));
}
}
for (flag, condition) in &from_default_if {
bind_flag_fallback(
flag,
std::slice::from_ref(&condition.value),
&mut out,
custom_env,
ValueOrigin::DefaultIf {
selector: condition.selector.clone(),
when: condition.when.clone(),
},
)?;
}
for flag in &flags {
if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) {
continue;
}
if !flag.default.is_empty() {
bind_flag_fallback(
flag,
&flag.default,
&mut out,
custom_env,
ValueOrigin::Default,
)?;
continue;
}
if let Some(arg) = flag.arg.as_ref() {
if !arg.default.is_empty() {
bind_flag_fallback(
flag,
&arg.default,
&mut out,
custom_env,
ValueOrigin::Default,
)?;
}
}
}
for (arg, parsed) in &out.args {
validate_expression(
&arg.name,
arg.validate.as_deref(),
arg.validate_error.as_deref(),
parsed,
&mut out.errors,
);
}
for (flag, parsed) in &out.flags {
if let Some(arg) = &flag.arg {
validate_expression(
&flag.name,
arg.validate.as_deref(),
arg.validate_error.as_deref(),
parsed,
&mut out.errors,
);
}
}
crate::warn::retain_reached(&mut out.warnings, self.spec.version.as_deref());
Ok(out)
}
}
#[must_use = "parsing result should be used"]
pub fn parse(spec: &Spec, input: &[String]) -> Result<ParseOutput, miette::Error> {
Parser::new(spec).parse(input)
}
#[must_use = "parsing result should be used"]
pub fn parse_partial(spec: &Spec, input: &[String]) -> Result<ParseOutput, miette::Error> {
parse_partial_with_env(spec, input, None, None, MountTiming::Eager).map(|(out, _)| out)
}
pub fn multicall_basename(argv0: &str) -> &str {
let name = argv0.rsplit(['/', '\\']).next().unwrap_or(argv0);
match name.get(name.len().saturating_sub(4)..) {
Some(ext) if ext.eq_ignore_ascii_case(".exe") => &name[..name.len() - 4],
_ => name,
}
}
pub fn multicall_applet<'a>(argv0: &'a str, name: &str, bin: Option<&str>) -> Option<&'a str> {
let base = multicall_basename(argv0);
if !name.is_empty() && base == multicall_basename(name) {
return None;
}
if let Some(bin) = bin {
if !bin.is_empty() && base == multicall_basename(bin) {
return None;
}
}
Some(base)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum MountTiming {
Eager,
WhenAWordIsUnknown,
}
struct Token {
word: String,
argv: usize,
binding: Option<(Arc<SpecFlag>, usize)>,
}
impl Token {
fn new(word: String, argv: usize) -> Self {
Self {
word,
argv,
binding: None,
}
}
}
struct Trace {
tokens: Vec<TokenBinding>,
}
impl Trace {
fn new(input: &[String]) -> Self {
Self {
tokens: input
.iter()
.enumerate()
.map(|(index, word)| TokenBinding {
index,
word: word.clone(),
synthesized: false,
roles: vec![],
})
.collect(),
}
}
fn record(&mut self, argv: usize, role: TokenRole) {
if let Some(token) = self.tokens.get_mut(argv) {
token.roles.push(role);
}
}
fn note_synthesized(&mut self, argv: usize) {
if let Some(token) = self.tokens.get_mut(argv) {
token.synthesized = true;
}
}
fn close(&mut self, unread: &VecDeque<Token>) {
for token in unread {
self.record(token.argv, TokenRole::Unread);
}
}
}
fn parse_partial_with_env(
spec: &Spec,
input: &[String],
custom_env: Option<&HashMap<String, String>>,
mount_outputs: Option<&HashMap<String, String>>,
mount_timing: MountTiming,
) -> Result<(ParseOutput, HashSet<String>), miette::Error> {
let mut trace = Trace::new(input);
parse_partial_traced(
spec,
input,
custom_env,
mount_outputs,
mount_timing,
&mut trace,
)
}
fn parse_partial_traced(
spec: &Spec,
input: &[String],
custom_env: Option<&HashMap<String, String>>,
mount_outputs: Option<&HashMap<String, String>>,
mount_timing: MountTiming,
trace: &mut Trace,
) -> Result<(ParseOutput, HashSet<String>), miette::Error> {
if let Some(view) = input.first().and_then(|argv0| spec.view_for_program(argv0)) {
let viewed = spec.for_view(view)?;
return parse_partial_traced(
&viewed,
input,
custom_env,
mount_outputs,
mount_timing,
trace,
);
}
trace!("parse_partial: {input:?}");
let mut input = input
.iter()
.enumerate()
.map(|(argv, word)| Token::new(word.clone(), argv))
.collect::<VecDeque<_>>();
let argv0 = input.pop_front();
if let Some(argv0) = argv0.as_ref() {
trace.record(argv0.argv, TokenRole::Program);
}
if spec.multicall {
if let Some(raw) = argv0 {
if let Some(applet) = multicall_applet(&raw.word, &spec.name, Some(spec.bin.as_str())) {
trace.note_synthesized(raw.argv);
input.push_front(Token::new(applet.to_string(), raw.argv));
}
}
}
let mut command_has_argv = !input.is_empty();
let mut out = ParseOutput {
cmd: spec.cmd.clone(),
cmds: vec![spec.cmd.clone()],
args: IndexMap::new(),
flags: IndexMap::new(),
tokens: vec![],
flag_origins: IndexMap::new(),
arg_origins: IndexMap::new(),
overridden_flags: BTreeMap::new(),
available_flags: gather_flags(&spec.cmd),
flag_awaiting_value: vec![],
errors: vec![],
warnings: vec![],
next_arg: None,
double_dash_seen: false,
external: None,
};
let mut overridden_flags = HashSet::new();
let mut parsed_flag_spellings: HashMap<usize, HashSet<String>> = HashMap::new();
let mut prefix_flags: Vec<(Arc<SpecFlag>, Vec<String>)> = vec![];
let mut command_arg_found = false;
let mut variadic_flag_active = false;
let mut idx = 0;
let mut used_default_subcommand = false;
let mut mounts_resolved = false;
let default_outranks_mounts =
spec.default_subcommand.is_some() && !out.cmd.mounts.iter().any(|m| m.overrides_default);
if mount_timing == MountTiming::Eager && !default_outranks_mounts && !out.cmd.mounts.is_empty()
{
mounts_resolved = true;
let mut mounted = out.cmd.clone();
mounted.mount(&[], mount_outputs)?;
merge_subcommand_flags(&mut out.available_flags, gather_flags(&mounted), false);
if let Some(last) = out.cmds.last_mut() {
*last = mounted.clone();
}
out.cmd = mounted;
}
while idx < input.len() {
let default_catches_it = spec.default_subcommand.as_deref().is_some_and(|name| {
default_accepts_word(&out.cmd, name, &input[idx].word)
&& !out.cmd.mounts.iter().any(|m| m.overrides_default)
});
if !mounts_resolved
&& !out.cmd.mounts.is_empty()
&& !default_catches_it
&& is_command_word(&input[idx].word)
&& !is_negative_number(&input[idx].word)
&& out.cmd.find_subcommand(&input[idx].word).is_none()
{
mounts_resolved = true;
let mut mounted = out.cmd.clone();
mounted.mount(&mount_prefix_words(&prefix_flags), mount_outputs)?;
merge_subcommand_flags(&mut out.available_flags, gather_flags(&mounted), false);
if let Some(last) = out.cmds.last_mut() {
*last = mounted.clone();
}
out.cmd = mounted;
}
if variadic_flag_active
&& out.cmd.find_subcommand(&input[idx].word).is_some()
&& !out.cmd.subcommand_precedence_over_arg
{
break;
}
if let Some(subcommand) = out.cmd.find_subcommand(&input[idx].word) {
if out.cmd.args_conflicts_with_subcommands && command_arg_found {
bail!(
"subcommand '{}' cannot be used with arguments on its parent command",
input[idx].word
);
}
let mut subcommand = subcommand.clone();
subcommand.mount(&mount_prefix_words(&prefix_flags), mount_outputs)?;
let crossing_mount = subcommand.mounted && !out.cmd.mounted;
merge_subcommand_flags(
&mut out.available_flags,
gather_flags(&subcommand),
crossing_mount,
);
let selected = input.remove(idx);
if let Some(selected) = selected {
trace.record(
selected.argv,
TokenRole::Command {
name: subcommand.name.clone(),
},
);
}
command_has_argv = idx < input.len();
out.cmds.push(subcommand.clone());
out.cmd = subcommand.clone();
mounts_resolved = true;
prefix_flags.clear();
command_arg_found = false;
variadic_flag_active = false;
} else if !is_command_word(&input[idx].word)
|| declared_numeric_short(&out.available_flags, &input[idx].word)
{
let word = input[idx].word.clone();
let flag_key = get_flag_key(&word);
let is_bundle = word.starts_with("--")
|| short_bundle_is_known(spec, &out.cmds, &out.available_flags, &word);
if let Some(f) = out
.available_flags
.get(flag_key)
.cloned()
.filter(|_| is_bundle)
{
command_arg_found = true;
variadic_flag_active = f.arg.as_ref().is_some_and(|arg| arg.var);
input[idx].binding = Some((Arc::clone(&f), out.cmds.len() - 1));
let mut forwarded = f.global.then(|| vec![word.clone()]);
idx += 1;
if f.arg.is_some()
&& !word.contains('=')
&& idx < input.len()
&& (!is_flag_like(&input[idx].word)
|| (f.arg.as_ref().is_some_and(|arg| arg.allow_negative_numbers)
&& is_negative_number(&input[idx].word)))
{
if let Some(words) = forwarded.as_mut() {
words.push(input[idx].word.clone());
}
idx += 1;
}
if let Some(words) = forwarded {
apply_prefix_flag_overrides(&mut prefix_flags, Arc::clone(&f));
prefix_flags.push((f, words));
}
} else {
break;
}
} else {
if variadic_flag_active && out.cmd.subcommand_precedence_over_arg {
idx += 1;
continue;
}
if !used_default_subcommand && out.cmds.len() == 1 {
if let Some(default_name) = &spec.default_subcommand {
if let Some(subcommand) = out
.cmd
.find_subcommand(default_name)
.filter(|_| default_accepts_word(&out.cmd, default_name, &input[idx].word))
{
if out.cmd.args_conflicts_with_subcommands && command_arg_found {
bail!(
"subcommand '{}' cannot be used with arguments on its parent command",
subcommand.name
);
}
let mut subcommand = subcommand.clone();
subcommand.mount(&mount_prefix_words(&prefix_flags), mount_outputs)?;
let crossing_mount = subcommand.mounted && !out.cmd.mounted;
merge_subcommand_flags(
&mut out.available_flags,
gather_flags(&subcommand),
crossing_mount,
);
out.cmds.push(subcommand.clone());
out.cmd = subcommand.clone();
command_has_argv = true;
prefix_flags.clear();
command_arg_found = false;
variadic_flag_active = false;
mounts_resolved = true;
used_default_subcommand = true;
continue;
}
}
}
if out.cmd.external_subcommand {
let rest: Vec<Token> = input.drain(idx..).collect();
for token in &rest {
trace.record(token.argv, TokenRole::External);
}
out.external = Some(rest.into_iter().map(|t| t.word).collect());
break;
}
break;
}
}
let mut next_arg_idx: usize = 0;
let mut enable_flags = true;
let mut grouped_flag = false;
let mut seen_double_dash = false;
let mut double_dash_violations: HashSet<String> = HashSet::new();
let mut scalar_occurrences: HashMap<(usize, usize), u8> = HashMap::new();
while !input.is_empty() {
let token = input.pop_front().unwrap();
let binding = token.binding;
let argv = token.argv;
let mut w = token.word;
let attached_continuation = grouped_flag;
if let Some(ref restart_token) = out.cmd.restart_token {
if w == *restart_token {
out.args.clear();
out.arg_origins.clear();
trace.record(argv, TokenRole::Restart);
next_arg_idx = 0;
out.flag_awaiting_value.clear(); enable_flags = true; seen_double_dash = false; continue;
}
}
if enable_flags
&& w.starts_with('-')
&& out.flag_awaiting_value.last().is_some_and(|flag| {
!flag.require_equals
&& (flag.allow_hyphen_values()
|| (flag
.arg
.as_ref()
.is_some_and(|arg| arg.allow_negative_numbers)
&& is_negative_number(&w)))
})
{
let should_return = bind_pending_flag_value(
spec,
&out.cmd,
&mut out.errors,
&mut out.flags,
&mut out.flag_awaiting_value,
&mut w,
&mut input,
custom_env,
trace,
argv,
false,
)?;
if should_return {
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
continue;
}
if enable_flags
&& !attached_continuation
&& !out.flag_awaiting_value.is_empty()
&& out.flag_awaiting_value.last().is_some_and(|flag| {
(flag.default_missing.is_some() || flag.value_optional)
&& (flag.require_equals
|| (is_flag_like(&w)
&& !flag.allow_hyphen_values()
&& !(flag
.arg
.as_ref()
.is_some_and(|arg| arg.allow_negative_numbers)
&& is_negative_number(&w))))
})
{
try_bind_default_missing(
&mut out.flags,
&mut out.flag_awaiting_value,
custom_env,
&mut out.flag_origins,
)?;
}
if w == "--" && !seen_double_dash {
enable_flags = false;
let should_preserve = out
.cmd
.args
.get(next_arg_idx)
.map(|arg| arg.var && arg.double_dash == SpecDoubleDashChoices::Preserve)
.unwrap_or(false);
if should_preserve {
} else {
seen_double_dash = true;
trace.record(argv, TokenRole::Separator);
let target = out.cmd.args.iter().position(|arg| {
arg.double_dash == SpecDoubleDashChoices::Required
&& !out.args.contains_key(arg)
});
if let Some(target) = target {
if target > next_arg_idx {
next_arg_idx = target;
}
}
continue;
}
}
if enable_flags && w.starts_with("--") {
grouped_flag = false;
let split = w.split_once('=');
let word = split.map(|(word, _)| word).unwrap_or(&w);
let bound_flag = binding.as_ref().map(|(flag, _)| flag);
if let Some(f) = bound_flag.or_else(|| out.available_flags.get(word)) {
let command_level = binding
.as_ref()
.map(|(_, level)| *level)
.unwrap_or(out.cmds.len() - 1);
parsed_flag_spellings
.entry(Arc::as_ptr(f) as usize)
.or_default()
.insert(word.to_string());
trace.record(
argv,
TokenRole::Flag {
flag: Arc::clone(f),
spelling: word.to_string(),
negated: f.negate.as_deref() == Some(word),
},
);
if f.action != crate::SpecFlagAction::Set {
out.errors.push(render_action_err(spec, &out.cmd, f, word));
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
apply_flag_overrides(
f,
&out.available_flags,
&mut out.flags,
&mut out.flag_awaiting_value,
&mut overridden_flags,
&mut out.overridden_flags,
);
if f.arg.is_some() {
record_scalar_flag_occurrence(
&out.cmds,
f,
command_level,
None,
&mut scalar_occurrences,
&mut out.errors,
);
let f = Arc::clone(f);
out.flag_awaiting_value.push(Arc::clone(&f));
if let Some((_, val)) = split {
let mut val = val.to_string();
let should_return = bind_pending_flag_value(
spec,
&out.cmd,
&mut out.errors,
&mut out.flags,
&mut out.flag_awaiting_value,
&mut val,
&mut input,
custom_env,
trace,
argv,
true,
)?;
if should_return {
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
}
} else if f.count {
let arr = out
.flags
.entry(Arc::clone(f))
.or_insert_with(|| ParseValue::MultiBool(vec![]))
.try_as_multi_bool_mut()
.unwrap();
arr.push(true);
} else {
let negate = f.negate.clone().unwrap_or_default();
let negated_form = word == negate;
let value = if f.bool_value {
match split.map(|(_, value)| value) {
Some("true") => !negated_form,
Some("false") => negated_form,
Some(value) => {
out.errors.push(UsageErr::InvalidValue {
name: f.name.clone(),
value: value.to_string(),
reason: "expected `true` or `false`".to_string(),
});
continue;
}
None => !negated_form,
}
} else {
!negated_form
};
record_scalar_flag_occurrence(
&out.cmds,
f,
command_level,
Some(!negated_form),
&mut scalar_occurrences,
&mut out.errors,
);
out.flags.insert(Arc::clone(f), ParseValue::Bool(value));
}
continue;
}
if is_help_arg(spec, &out.cmd, &w) {
out.errors
.push(render_help_err(spec, &out.cmd, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if is_version_arg(spec, &out.cmds, &w) {
out.errors.push(render_version_err(spec, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if let Err(refused) = reject_unknown_flag_if_asked(spec, &out.cmds, &w) {
trace.record(argv, TokenRole::UnknownFlag { bound_as: None });
trace.close(&input);
return Err(refused.into());
}
}
let declared_numeric_short = declared_numeric_short(&out.available_flags, &w);
let positional_negative_number = !declared_numeric_short
&& is_negative_number(&w)
&& out
.cmd
.args
.get(next_arg_idx)
.is_some_and(|arg| arg.allow_negative_numbers);
if enable_flags
&& !grouped_flag
&& binding.is_none()
&& w.starts_with('-')
&& w.len() > 1
&& is_flag_like(&w)
&& !positional_negative_number
&& !short_bundle_is_known(spec, &out.cmds, &out.available_flags, &w)
{
if let Err(refused) = reject_unknown_flag_if_asked(spec, &out.cmds, &w) {
trace.record(argv, TokenRole::UnknownFlag { bound_as: None });
trace.close(&input);
return Err(refused.into());
}
} else if enable_flags && !positional_negative_number && w.starts_with('-') && w.len() > 1 {
let short = w.chars().nth(1).unwrap();
if let Some(f) = binding
.as_ref()
.map(|(flag, _)| flag)
.or_else(|| out.available_flags.get(&format!("-{short}")))
{
let command_level = binding
.as_ref()
.map(|(_, level)| *level)
.unwrap_or(out.cmds.len() - 1);
if f.action != crate::SpecFlagAction::Set {
out.errors
.push(render_action_err(spec, &out.cmd, f, &format!("-{short}")));
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
parsed_flag_spellings
.entry(Arc::as_ptr(f) as usize)
.or_default()
.insert(format!("-{short}"));
trace.record(
argv,
TokenRole::Flag {
flag: Arc::clone(f),
spelling: format!("-{short}"),
negated: false,
},
);
apply_flag_overrides(
f,
&out.available_flags,
&mut out.flags,
&mut out.flag_awaiting_value,
&mut overridden_flags,
&mut out.overridden_flags,
);
let rest = &w[1 + short.len_utf8()..];
if !rest.is_empty() {
input.push_front(Token::new(format!("-{rest}"), argv));
}
grouped_flag = !rest.is_empty();
if f.arg.is_some() {
record_scalar_flag_occurrence(
&out.cmds,
f,
command_level,
None,
&mut scalar_occurrences,
&mut out.errors,
);
out.flag_awaiting_value.push(Arc::clone(f));
} else if f.count {
let arr = out
.flags
.entry(Arc::clone(f))
.or_insert_with(|| ParseValue::MultiBool(vec![]))
.try_as_multi_bool_mut()
.unwrap();
arr.push(true);
} else {
let negate = f.negate.clone().unwrap_or_default();
let value = w != negate;
record_scalar_flag_occurrence(
&out.cmds,
f,
command_level,
Some(value),
&mut scalar_occurrences,
&mut out.errors,
);
out.flags.insert(Arc::clone(f), ParseValue::Bool(value));
}
continue;
}
if let Some(err) = supplied_short(spec, &out.cmds, short) {
out.errors.push(err);
trace.record(
argv,
TokenRole::Builtin {
spelling: format!("-{short}"),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if is_help_arg(spec, &out.cmd, &w) {
out.errors
.push(render_help_err(spec, &out.cmd, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if is_version_arg(spec, &out.cmds, &w) {
out.errors.push(render_version_err(spec, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if let Err(refused) = reject_unknown_flag_if_asked(spec, &out.cmds, &w) {
trace.record(argv, TokenRole::UnknownFlag { bound_as: None });
trace.close(&input);
return Err(refused.into());
}
if grouped_flag {
grouped_flag = false;
w.remove(0);
if !out.flag_awaiting_value.is_empty() && w.starts_with('=') {
w.remove(0);
}
}
}
if enable_flags
&& !attached_continuation
&& !out.flag_awaiting_value.is_empty()
&& out
.flag_awaiting_value
.last()
.is_some_and(|flag| flag.require_equals)
{
let flag = out.flag_awaiting_value.last().unwrap();
let token = flag
.long
.first()
.map(|l| format!("--{l}"))
.or_else(|| flag.short.first().map(|s| format!("-{s}")))
.unwrap_or_else(|| flag.name.clone());
out.errors.push(UsageErr::InvalidFlag {
token: token.clone(),
reason: "requires an argument".to_string(),
span: (0, 0).into(),
input: format!("{token} {w}"),
});
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if enable_flags && !out.flag_awaiting_value.is_empty() {
let should_return = bind_pending_flag_value(
spec,
&out.cmd,
&mut out.errors,
&mut out.flags,
&mut out.flag_awaiting_value,
&mut w,
&mut input,
custom_env,
trace,
argv,
attached_continuation,
)?;
if should_return {
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
continue;
}
if out.cmd.allow_missing_positional {
while let Some(current) = out.cmd.args.get(next_arg_idx) {
if current.required || out.args.contains_key(current) {
break;
}
let required_after = out.cmd.args[next_arg_idx + 1..]
.iter()
.filter(|arg| arg.required)
.count();
if required_after == 0 {
break;
}
let remaining_values = 1 + input
.iter()
.filter(|token| !enable_flags || !is_flag_like(&token.word))
.count();
if remaining_values > required_after {
break;
}
next_arg_idx += 1;
}
}
if let Some(arg) = out.cmd.args.get(next_arg_idx) {
if arg.var
&& out.args.contains_key(arg)
&& arg.value_terminator.as_deref() == Some(w.as_str())
{
trace.record(
argv,
TokenRole::ValueTerminator {
ends: arg.name.clone(),
},
);
next_arg_idx += 1;
continue;
}
if arg.double_dash == SpecDoubleDashChoices::Required && !seen_double_dash {
report_double_dash_violation(arg, &mut out.errors, &mut double_dash_violations);
trace.record(
argv,
TokenRole::Refused {
reason: format!("{} only accepts words after `--`", arg.name),
},
);
continue;
}
let trailing_value =
seen_double_dash || arg.double_dash == SpecDoubleDashChoices::Automatic;
let suppress_trailing_delimiter =
out.cmds.iter().any(|cmd| cmd.dont_delimit_trailing_values);
let delimiter = if suppress_trailing_delimiter && trailing_value {
None
} else {
arg.delimiter
};
let parts: Vec<String> = match delimiter {
Some(delimiter) => w.split(delimiter).map(str::to_string).collect(),
None => vec![w.clone()],
};
let mut refused = false;
for part in &parts {
if validate_choices(
spec,
&out.cmd,
&mut out.errors,
ChoiceTarget::arg(arg),
part,
arg.choices.as_ref(),
custom_env,
)? {
refused = true;
break;
}
}
if refused {
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if arg.double_dash == SpecDoubleDashChoices::Automatic {
enable_flags = false;
}
let unknown_flag = enable_flags
&& !positional_negative_number
&& is_flag_like(&w)
&& binding.is_none();
trace.record(
argv,
if unknown_flag {
TokenRole::UnknownFlag {
bound_as: Some(Arc::new(arg.clone())),
}
} else {
TokenRole::Arg {
arg: Arc::new(arg.clone()),
values: parts.clone(),
}
},
);
if arg.var {
let arr = out
.args
.entry(Arc::new(arg.clone()))
.or_insert_with(|| ParseValue::MultiString(vec![]))
.try_as_multi_string_mut()
.unwrap();
arr.extend(parts.iter().cloned());
if arr.len() >= arg.var_max.unwrap_or(usize::MAX) {
next_arg_idx += 1;
}
} else {
out.args
.insert(Arc::new(arg.clone()), ParseValue::String(w));
next_arg_idx += 1;
}
continue;
}
if is_help_arg(spec, &out.cmd, &w) {
out.errors
.push(render_help_err(spec, &out.cmd, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
if is_version_arg(spec, &out.cmds, &w) {
out.errors.push(render_version_err(spec, w.len() > 2));
trace.record(
argv,
TokenRole::Builtin {
spelling: w.clone(),
},
);
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
return Ok((out, overridden_flags));
}
trace.record(
argv,
TokenRole::Refused {
reason: "no declaration takes this word".to_string(),
},
);
trace.close(&input);
bail!("unexpected word: {w}");
}
record_stop(&mut out, next_arg_idx, seen_double_dash, trace, &input);
let flag_was_parsed =
|flag: &Arc<SpecFlag>| parsed_flag_spellings.contains_key(&(Arc::as_ptr(flag) as usize));
let child_spellings = |flag: &Arc<SpecFlag>| -> HashSet<String> {
let declared: HashSet<String> = out
.cmd
.flags
.iter()
.filter(|declared| declared.name == flag.name)
.flat_map(flag_keys)
.collect();
let speaks_for_this_flag = declared.iter().any(|spelling| {
out.available_flags
.get(spelling)
.is_some_and(|available| Arc::ptr_eq(available, flag))
});
if speaks_for_this_flag {
declared
} else {
HashSet::new()
}
};
let exclusivity_in_play = |flag: &Arc<SpecFlag>| -> (bool, bool) {
let child = child_spellings(flag);
let child_exclusive = !child.is_empty()
&& out
.cmd
.flags
.iter()
.any(|declared| declared.name == flag.name && declared.exclusive);
match parsed_flag_spellings.get(&(Arc::as_ptr(flag) as usize)) {
Some(spellings) => (
child_exclusive && spellings.iter().any(|s| child.contains(s)),
flag.exclusive && spellings.iter().any(|s| !child.contains(s)),
),
None => (child_exclusive, flag.exclusive && child.is_empty()),
}
};
let exclusive_occurrence = |flag: &Arc<SpecFlag>| {
let (child, ancestor) = exclusivity_in_play(flag);
child || ancestor
};
let exclusive_present =
unique_flags(out.available_flags.values().chain(out.flags.keys())).any(|flag| {
exclusive_occurrence(flag)
&& !overridden_flags.contains(&flag.name)
&& (flag_was_parsed(flag) || flag_has_env(flag, custom_env))
});
let requirements_apply = |command_index: usize| {
command_index + 1 == out.cmds.len() || !out.cmds[command_index].subcommand_negates_reqs
};
if out.cmd.arg_required_else_help && !command_has_argv {
out.errors.push(render_help_err(spec, &out.cmd, false));
}
if out.cmd.subcommand_required && !out.cmd.subcommands.is_empty() && out.external.is_none() {
let mut names: Vec<&str> = out
.cmd
.subcommands
.iter()
.filter(|(name, sub)| sub.name == **name && !sub.hide)
.map(|(name, _)| name.as_str())
.collect();
names.sort_unstable();
out.errors.push(UsageErr::MissingSubcommand(
out.cmd.name.clone(),
names.join(", "),
));
}
if !exclusive_present {
for arg in out
.cmds
.iter()
.enumerate()
.filter(|(index, _)| requirements_apply(*index))
.flat_map(|(_, cmd)| &cmd.args)
{
if out.args.contains_key(arg) {
continue;
}
if double_dash_violations.contains(&arg.name) {
continue;
}
let required_if = arg.required_if.iter().any(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let required_if_eq = arg.required_if_eq.iter().any(|condition| {
selector_explicit_has_value(
&condition.selector,
&condition.value,
&out,
&overridden_flags,
custom_env,
)
});
let required_if_eq_all = !arg.required_if_eq_all.is_empty()
&& arg.required_if_eq_all.iter().all(|condition| {
selector_explicit_has_value(
&condition.selector,
&condition.value,
&out,
&overridden_flags,
custom_env,
)
});
let unless_any = arg.required_unless.iter().any(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let unless_all = !arg.required_unless_all.is_empty()
&& arg.required_unless_all.iter().all(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let required_unless = (!arg.required_unless.is_empty()
|| !arg.required_unless_all.is_empty())
&& !(unless_any || unless_all);
if (arg.required
|| required_if
|| required_if_eq
|| required_if_eq_all
|| required_unless)
&& arg.default.is_empty()
{
let has_env = arg
.env
.as_ref()
.is_some_and(|env_var| env_contains(custom_env, env_var));
if !has_env {
out.errors.push(UsageErr::MissingArg(arg.name.clone()));
}
}
}
}
for flag in unique_flags(out.available_flags.values()) {
let given = out.flags.contains_key(flag) || flag_has_env(flag, custom_env);
if !given || overridden_flags.contains(&flag.name) {
continue;
}
for other in &flag.conflicts {
if selector_is_explicit(other, &out, &overridden_flags, custom_env) {
out.errors.push(UsageErr::InvalidFlag {
token: format!("--{}", flag.name),
reason: format!("conflicts with {other}"),
span: (0, 0).into(),
input: format!("--{} {other}", flag.name),
});
}
}
let owner = out
.cmds
.iter()
.rposition(|cmd| cmd.flags.iter().any(|declared| declared.name == flag.name))
.unwrap_or(out.cmds.len() - 1);
if !exclusive_present && requirements_apply(owner) {
for other in &flag.requires {
if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) {
let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone());
if other.starts_with('-') {
out.errors.push(UsageErr::MissingFlag(name));
} else {
out.errors.push(UsageErr::MissingArg(name));
}
}
}
for condition in &flag.requires_if {
if explicit_flag_has_value(flag, &condition.value, &out, custom_env)
&& !selector_is_satisfied(
&condition.requires,
&out,
&overridden_flags,
custom_env,
)
{
let name = selector_flag_name(&condition.requires, &out)
.unwrap_or_else(|| condition.requires.clone());
out.errors.push(UsageErr::MissingFlag(name));
}
}
}
}
for (command_index, arg) in out
.cmds
.iter()
.enumerate()
.flat_map(|(index, cmd)| cmd.args.iter().map(move |arg| (index, arg)))
{
let given = arg_is_explicit(arg, &out, custom_env);
if !given {
continue;
}
for other in &arg.conflicts {
if selector_is_explicit(other, &out, &overridden_flags, custom_env) {
out.errors.push(UsageErr::InvalidFlag {
token: arg.name.clone(),
reason: format!("conflicts with {other}"),
span: (0, 0).into(),
input: format!("{} {other}", arg.name),
});
}
}
if !exclusive_present && requirements_apply(command_index) {
for other in &arg.requires {
if !selector_is_satisfied(other, &out, &overridden_flags, custom_env) {
let name = selector_flag_name(other, &out).unwrap_or_else(|| other.clone());
if other.starts_with('-') {
out.errors.push(UsageErr::MissingFlag(name));
} else {
out.errors.push(UsageErr::MissingArg(name));
}
}
}
}
}
for flag in unique_flags(out.available_flags.values().chain(out.flags.keys())) {
let given = flag_was_parsed(flag) || flag_has_env(flag, custom_env);
if !exclusive_occurrence(flag) || !given || overridden_flags.contains(&flag.name) {
continue;
}
let other_flag = unique_flags(out.available_flags.values().chain(out.flags.keys()))
.find(|other| {
!Arc::ptr_eq(other, flag)
&& !overridden_flags.contains(&other.name)
&& (flag_was_parsed(other) || flag_has_env(other, custom_env))
})
.map(|other| format!("--{}", other.name));
let other_arg = out.cmd.args.iter().find(|arg| {
out.args.keys().any(|given| given.name == arg.name)
|| arg
.env
.as_ref()
.is_some_and(|env| env_contains(custom_env, env))
});
let (_, ancestor_exclusivity) = exclusivity_in_play(flag);
let selected_subcommand =
(out.cmds.len() > 1 && ancestor_exclusivity).then(|| out.cmd.name.clone());
let other = other_flag
.or_else(|| other_arg.map(|arg| format!("<{}>", arg.name)))
.or(selected_subcommand);
if let Some(other) = other {
out.errors.push(UsageErr::InvalidFlag {
token: format!("--{}", flag.name),
reason: format!("must be given on its own, and {other} was given too"),
span: (0, 0).into(),
input: format!("--{} {other}", flag.name),
});
}
}
let mut group_errors: Vec<UsageErr> = Vec::new();
for (command_index, group) in out
.cmds
.iter()
.enumerate()
.flat_map(|(index, cmd)| cmd.groups.iter().map(move |group| (index, group)))
{
let mut given: Vec<&str> = Vec::new();
let mut seen: Vec<String> = Vec::new();
for selector in &group.members {
if !selector_is_explicit(selector, &out, &overridden_flags, custom_env) {
continue;
}
let name = selector_flag_name(selector, &out).unwrap_or_else(|| selector.clone());
if seen.contains(&name) {
continue;
}
seen.push(name);
given.push(selector.as_str());
}
if !group.multiple && given.len() > 1 {
group_errors.push(UsageErr::InvalidFlag {
token: given[1].to_string(),
reason: format!("cannot be used with {} in group {}", given[0], group.name),
span: (0, 0).into(),
input: format!("{} {}", given[0], given[1]),
});
}
let satisfied = group
.members
.iter()
.any(|selector| selector_is_satisfied(selector, &out, &overridden_flags, custom_env));
if group.required && requirements_apply(command_index) && !satisfied && !exclusive_present {
group_errors.push(UsageErr::MissingGroup {
group: group.name.clone(),
members: group.members.join(", "),
});
}
}
out.errors.extend(group_errors);
if !exclusive_present {
for flag in unique_flags(out.available_flags.values()) {
let owner = out
.cmds
.iter()
.rposition(|cmd| cmd.flags.iter().any(|declared| declared.name == flag.name))
.unwrap_or(out.cmds.len() - 1);
if !requirements_apply(owner) {
continue;
}
if out.flags.contains_key(flag) || overridden_flags.contains(&flag.name) {
continue;
}
let has_default =
!flag.default.is_empty() || flag.arg.iter().any(|a| !a.default.is_empty());
let has_env = flag_has_env(flag, custom_env);
let required_if = flag.required_if.iter().any(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let required_if_eq = flag.required_if_eq.iter().any(|condition| {
selector_explicit_has_value(
&condition.selector,
&condition.value,
&out,
&overridden_flags,
custom_env,
)
});
let required_if_eq_all = !flag.required_if_eq_all.is_empty()
&& flag.required_if_eq_all.iter().all(|condition| {
selector_explicit_has_value(
&condition.selector,
&condition.value,
&out,
&overridden_flags,
custom_env,
)
});
let unless_any = flag.required_unless.iter().any(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let unless_all = !flag.required_unless_all.is_empty()
&& flag.required_unless_all.iter().all(|selector| {
selector_is_explicit(selector, &out, &overridden_flags, custom_env)
});
let required_unless = (!flag.required_unless.is_empty()
|| !flag.required_unless_all.is_empty())
&& !(unless_any || unless_all);
if (flag.required
|| required_if
|| required_if_eq
|| required_if_eq_all
|| required_unless)
&& !has_default
&& !has_env
{
out.errors.push(UsageErr::MissingFlag(flag.name.clone()));
}
}
}
for (arg, value) in &out.args {
if arg.var {
if let ParseValue::MultiString(values) = value {
if let Some(min) = arg.var_min {
if values.len() < min {
out.errors.push(UsageErr::VarArgTooFew {
name: arg.name.clone(),
min,
got: values.len(),
});
}
}
if let Some(max) = arg.var_max {
if values.len() > max {
out.errors.push(UsageErr::VarArgTooMany {
name: arg.name.clone(),
max,
got: values.len(),
});
}
}
}
}
}
for flag in unique_flags(out.available_flags.values()) {
if flag.var {
let bound = match out.flags.get(flag) {
Some(ParseValue::MultiString(values)) => values.len(),
Some(ParseValue::MultiBool(values)) => values.len(),
Some(_) => 1,
None => 0,
};
let pending = out
.flag_awaiting_value
.iter()
.filter(|pending| {
Arc::ptr_eq(pending, flag)
&& (pending.value_optional || pending.default_missing.is_some())
})
.count();
let count = bound + pending;
if count == 0 {
continue;
}
if let Some(min) = flag.var_min {
if count < min {
out.errors.push(UsageErr::VarFlagTooFew {
name: flag.name.clone(),
min,
got: count,
});
}
}
if let Some(max) = flag.var_max {
if count > max {
out.errors.push(UsageErr::VarFlagTooMany {
name: flag.name.clone(),
max,
got: count,
});
}
}
}
}
Ok((out, overridden_flags))
}
fn validate_expression(
name: &str,
expression: Option<&str>,
message: Option<&str>,
parsed: &ParseValue,
errors: &mut Vec<UsageErr>,
) {
let Some(expression) = expression else {
return;
};
#[cfg(not(feature = "validation"))]
let _ = expression;
let values: &[String] = match parsed {
ParseValue::String(value) => std::slice::from_ref(value),
ParseValue::MultiString(values) => values,
ParseValue::Bool(_) | ParseValue::MultiBool(_) => return,
};
#[cfg(feature = "validation")]
for value in values {
let reason = match usage_validation::validate(expression, value) {
Ok(true) => continue,
Ok(false) => message
.unwrap_or("does not satisfy the validation expression")
.to_string(),
Err(error) => format!("validation expression failed: {error}"),
};
errors.push(UsageErr::InvalidValue {
name: name.to_string(),
value: value.clone(),
reason,
});
break;
}
#[cfg(not(feature = "validation"))]
if let Some(value) = values.first() {
let _ = message;
errors.push(UsageErr::InvalidValue {
name: name.to_string(),
value: value.clone(),
reason: "expression validation requires the `validation` feature".to_string(),
});
}
}
#[cfg(all(test, not(feature = "validation")))]
mod optional_validation_tests {
use crate::{parse, Spec};
#[test]
fn validation_declarations_require_the_opt_in_runtime_feature() {
let spec: Spec = r#"
name "ex"
bin "ex"
arg "<port>" validate="int(value) > 0"
"#
.parse()
.unwrap();
let error = parse(&spec, &["ex".to_string(), "1".to_string()]).unwrap_err();
assert!(
error
.to_string()
.contains("requires the `validation` feature"),
"{error:?}"
);
}
}
fn flag_matches_selector(flag: &SpecFlag, selector: &str) -> bool {
flag.name == selector || flag_keys(flag).iter().any(|key| key == selector)
}
fn flags_override(overrider: &SpecFlag, overridden: &SpecFlag) -> bool {
overrider
.overrides
.iter()
.any(|selector| flag_matches_selector(overridden, selector))
}
fn apply_prefix_flag_overrides(
prefix_flags: &mut Vec<(Arc<SpecFlag>, Vec<String>)>,
flag: Arc<SpecFlag>,
) {
prefix_flags
.retain(|(other, _)| !(flags_override(&flag, other) || flags_override(other, &flag)));
}
fn mount_prefix_words(prefix_flags: &[(Arc<SpecFlag>, Vec<String>)]) -> Vec<String> {
prefix_flags
.iter()
.flat_map(|(_, words)| words.iter().cloned())
.collect()
}
fn env_contains(custom_env: Option<&HashMap<String, String>>, env_var: &str) -> bool {
match custom_env {
Some(env) => env.contains_key(env_var),
None => std::env::var(env_var).is_ok(),
}
}
fn flag_has_env(flag: &SpecFlag, custom_env: Option<&HashMap<String, String>>) -> bool {
flag.env_names()
.any(|env_var| env_contains(custom_env, env_var))
}
fn fallback_is_true(value: &str) -> bool {
matches!(value, "1" | "true" | "True" | "TRUE")
}
fn split_fallback_values(values: &[String], delimiter: Option<char>) -> Vec<String> {
match delimiter {
Some(delimiter) => values
.iter()
.flat_map(|value| value.split(delimiter).map(str::to_string))
.collect(),
None => values.to_vec(),
}
}
fn validate_arg_fallback_count(arg: &SpecArg, count: usize, errors: &mut Vec<UsageErr>) {
if let Some(min) = arg.var_min {
if count < min {
errors.push(UsageErr::VarArgTooFew {
name: arg.name.clone(),
min,
got: count,
});
}
}
if let Some(max) = arg.var_max {
if count > max {
errors.push(UsageErr::VarArgTooMany {
name: arg.name.clone(),
max,
got: count,
});
}
}
}
fn validate_flag_fallback_count(flag: &SpecFlag, count: usize, errors: &mut Vec<UsageErr>) {
if let Some(min) = flag.var_min {
if count < min {
errors.push(UsageErr::VarFlagTooFew {
name: flag.name.clone(),
min,
got: count,
});
}
}
if let Some(max) = flag.var_max {
if count > max {
errors.push(UsageErr::VarFlagTooMany {
name: flag.name.clone(),
max,
got: count,
});
}
}
}
fn validate_flag_arg_fallback_count(
flag: &SpecFlag,
arg: &SpecArg,
count: usize,
errors: &mut Vec<UsageErr>,
) {
if let Some(min) = arg.var_min {
if count < min {
errors.push(UsageErr::VarFlagTooFew {
name: flag.name.clone(),
min,
got: count,
});
}
}
if let Some(max) = arg.var_max {
if count > max {
errors.push(UsageErr::VarFlagTooMany {
name: flag.name.clone(),
max,
got: count,
});
}
}
}
fn bind_flag_fallback(
flag: &Arc<SpecFlag>,
values: &[String],
out: &mut ParseOutput,
custom_env: Option<&HashMap<String, String>>,
origin: ValueOrigin,
) -> Result<(), miette::Error> {
if values.is_empty() {
return Ok(());
}
if let Some(arg) = flag.arg.as_ref() {
let values = split_fallback_values(values, arg.delimiter);
if flag.var || arg.var {
if flag.var {
validate_flag_fallback_count(flag, values.len(), &mut out.errors);
}
if arg.var {
validate_flag_arg_fallback_count(flag, arg, values.len(), &mut out.errors);
}
validate_choice_values(
ChoiceTarget::option(flag),
&values,
arg.choices.as_ref(),
custom_env,
)?;
out.flags
.insert(Arc::clone(flag), ParseValue::MultiString(values));
} else {
let value = values.into_iter().next().unwrap_or_default();
validate_choice_value(
ChoiceTarget::option(flag),
&value,
arg.choices.as_ref(),
custom_env,
)?;
out.flags
.insert(Arc::clone(flag), ParseValue::String(value));
}
} else if flag.var {
validate_flag_fallback_count(flag, values.len(), &mut out.errors);
let bools: Vec<bool> = values.iter().map(|s| fallback_is_true(s)).collect();
out.flags
.insert(Arc::clone(flag), ParseValue::MultiBool(bools));
} else {
out.flags.insert(
Arc::clone(flag),
ParseValue::Bool(fallback_is_true(&values[0])),
);
}
out.flag_origins
.entry(Arc::clone(flag))
.or_default()
.push(origin);
Ok(())
}
fn default_if_condition_matches(
condition: &crate::SpecDefaultIf,
out: &ParseOutput,
overridden_flags: &HashSet<String>,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
match &condition.when {
None => selector_is_explicit(&condition.selector, out, overridden_flags, custom_env),
Some(when) => {
let Some(flag) = out
.available_flags
.values()
.chain(out.flags.keys())
.find(|flag| flag_matches_selector(flag, &condition.selector))
else {
return false;
};
if overridden_flags.contains(&flag.name) {
return false;
}
explicit_flag_has_value(flag, when, out, custom_env)
}
}
}
fn explicit_flag_has_value(
flag: &SpecFlag,
expected: &str,
out: &ParseOutput,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
let parsed_matches = out.flags.get(flag).is_some_and(|value| match value {
ParseValue::Bool(value) => value.to_string() == expected,
ParseValue::String(value) => value == expected,
ParseValue::MultiBool(values) => values.iter().any(|value| value.to_string() == expected),
ParseValue::MultiString(values) => values.iter().any(|value| value == expected),
});
if out.flags.contains_key(flag) {
return parsed_matches;
}
let value = flag.env_names().find_map(|env| match custom_env {
Some(values) => values.get(env).cloned(),
None => std::env::var(env).ok(),
});
value.is_some_and(
|value| match flag.arg.as_ref().and_then(|arg| arg.delimiter) {
Some(delimiter) => value.split(delimiter).any(|value| value == expected),
None if flag.arg.is_none() => {
matches!(value.as_str(), "1" | "true" | "True" | "TRUE").to_string() == expected
}
None => value == expected,
},
)
}
fn selector_explicit_has_value(
selector: &str,
expected: &str,
out: &ParseOutput,
overridden_flags: &HashSet<String>,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
if let Some(flag) = out
.available_flags
.values()
.chain(out.flags.keys())
.find(|flag| flag_matches_selector(flag, selector))
{
return !overridden_flags.contains(&flag.name)
&& explicit_flag_has_value(flag, expected, out, custom_env);
}
let Some(arg) = selector_arg(selector, out) else {
return false;
};
let parsed = out
.args
.iter()
.find(|(given, _)| given.name == arg.name)
.map(|(_, value)| value);
if let Some(value) = parsed {
return match value {
ParseValue::String(value) => value == expected,
ParseValue::MultiString(values) => values.iter().any(|value| value == expected),
ParseValue::Bool(value) => value.to_string() == expected,
ParseValue::MultiBool(values) => {
values.iter().any(|value| value.to_string() == expected)
}
};
}
let value = arg.env_names().find_map(|env| match custom_env {
Some(values) => values.get(env).cloned(),
None => std::env::var(env).ok(),
});
value.is_some_and(|value| match arg.delimiter {
Some(delimiter) => value.split(delimiter).any(|value| value == expected),
None => value == expected,
})
}
fn selector_is_explicit(
selector: &str,
out: &ParseOutput,
overridden_flags: &HashSet<String>,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
let flag_is_explicit = out
.available_flags
.values()
.chain(out.flags.keys())
.any(|flag| {
flag_matches_selector(flag, selector)
&& !overridden_flags.contains(&flag.name)
&& (out.flags.contains_key(flag) || flag_has_env(flag, custom_env))
});
flag_is_explicit
|| selector_arg(selector, out).is_some_and(|arg| arg_is_explicit(arg, out, custom_env))
}
fn selector_flag_name(selector: &str, out: &ParseOutput) -> Option<String> {
out.available_flags
.values()
.chain(out.flags.keys())
.find(|flag| flag_matches_selector(flag, selector))
.map(|flag| flag.name.clone())
.or_else(|| selector_arg(selector, out).map(|arg| arg.name.clone()))
}
fn selector_is_satisfied(
selector: &str,
out: &ParseOutput,
overridden_flags: &HashSet<String>,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
if selector_is_explicit(selector, out, overridden_flags, custom_env) {
return true;
}
let flag_is_satisfied = out
.available_flags
.values()
.chain(out.flags.keys())
.filter(|flag| flag_matches_selector(flag, selector))
.any(|flag| {
!overridden_flags.contains(&flag.name)
&& (!flag.default.is_empty()
|| flag.arg.iter().any(|a| !a.default.is_empty())
|| flag.default_if.iter().any(|condition| {
default_if_condition_matches(condition, out, overridden_flags, custom_env)
}))
});
flag_is_satisfied || selector_arg(selector, out).is_some_and(|arg| !arg.default.is_empty())
}
fn selector_arg<'a>(selector: &str, out: &'a ParseOutput) -> Option<&'a SpecArg> {
if selector.starts_with('-') {
return None;
}
out.cmds
.iter()
.flat_map(|cmd| &cmd.args)
.find(|arg| arg.name == selector)
}
fn arg_is_explicit(
arg: &SpecArg,
out: &ParseOutput,
custom_env: Option<&HashMap<String, String>>,
) -> bool {
out.args.keys().any(|given| given.name == arg.name)
|| arg
.env
.as_ref()
.is_some_and(|env| env_contains(custom_env, env))
}
fn apply_flag_overrides(
flag: &Arc<SpecFlag>,
available_flags: &BTreeMap<String, Arc<SpecFlag>>,
parsed_flags: &mut IndexMap<Arc<SpecFlag>, ParseValue>,
pending_flags: &mut Vec<Arc<SpecFlag>>,
overridden_flags: &mut HashSet<String>,
attributed: &mut BTreeMap<String, String>,
) {
let overridden_names: HashSet<String> = available_flags
.values()
.chain(parsed_flags.keys())
.filter(|other| flags_override(flag, other) || flags_override(other, flag))
.map(|other| other.name.clone())
.collect();
parsed_flags.retain(|parsed, _| !overridden_names.contains(&parsed.name));
pending_flags.retain(|pending| !overridden_names.contains(&pending.name));
for name in &overridden_names {
attributed.insert(name.clone(), flag.name.clone());
}
overridden_flags.extend(overridden_names);
overridden_flags.remove(&flag.name);
attributed.remove(&flag.name);
}
#[cfg(feature = "docs")]
fn render_help_err(spec: &Spec, cmd: &SpecCommand, long: bool) -> UsageErr {
UsageErr::Help(docs::cli::render_help(spec, cmd, long))
}
#[cfg(feature = "docs")]
fn render_help_all_err(spec: &Spec, cmd: &SpecCommand) -> UsageErr {
fn append(out: &mut String, spec: &Spec, cmd: &SpecCommand) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&docs::cli::render_help(spec, cmd, true));
let mut children: Vec<_> = cmd
.subcommands
.values()
.filter(|child| !child.hide)
.collect();
children.sort_by_key(|child| (child.display_order.unwrap_or(999), child.name.as_str()));
for child in children {
append(out, spec, child);
}
}
let mut out = String::new();
append(&mut out, spec, cmd);
UsageErr::Help(out)
}
#[cfg(not(feature = "docs"))]
fn render_help_err(_spec: &Spec, _cmd: &SpecCommand, _long: bool) -> UsageErr {
UsageErr::Help("help".to_string())
}
#[cfg(not(feature = "docs"))]
fn render_help_all_err(_spec: &Spec, _cmd: &SpecCommand) -> UsageErr {
UsageErr::Help("help".to_string())
}
fn render_version_err(spec: &Spec, long: bool) -> UsageErr {
let value = if long {
spec.long_version.as_ref().or(spec.version.as_ref())
} else {
spec.version.as_ref().or(spec.long_version.as_ref())
};
UsageErr::Version(value.cloned().unwrap_or_default())
}
fn render_action_err(spec: &Spec, cmd: &SpecCommand, flag: &SpecFlag, spelling: &str) -> UsageErr {
use crate::SpecFlagAction;
match flag.action {
SpecFlagAction::Help => render_help_err(spec, cmd, spelling.starts_with("--")),
SpecFlagAction::HelpShort => render_help_err(spec, cmd, false),
SpecFlagAction::HelpLong => render_help_err(spec, cmd, true),
SpecFlagAction::HelpAll => render_help_all_err(spec, cmd),
SpecFlagAction::Version => render_version_err(spec, spelling.starts_with("--")),
SpecFlagAction::Set => unreachable!("binding actions are handled before this helper"),
}
}
#[derive(Copy, Clone)]
struct ChoiceTarget<'a> {
kind: &'a str,
name: &'a str,
}
impl<'a> ChoiceTarget<'a> {
fn arg(arg: &'a SpecArg) -> Self {
Self {
kind: "arg",
name: &arg.name,
}
}
fn option(flag: &'a SpecFlag) -> Self {
Self {
kind: "option",
name: &flag.name,
}
}
}
fn short_bundle_is_known(
spec: &Spec,
cmds: &[SpecCommand],
available: &BTreeMap<String, Arc<SpecFlag>>,
token: &str,
) -> bool {
for c in token.chars().skip(1) {
match available.get(&format!("-{c}")) {
None if supplied_short(spec, cmds, c).is_some() => {}
None => return false,
Some(f) if f.arg.is_some() => return true,
Some(_) => {}
}
}
true
}
fn supplied_short(spec: &Spec, cmds: &[SpecCommand], letter: char) -> Option<UsageErr> {
let cmd = cmds.last()?;
match letter {
'h' if is_help_arg(spec, cmd, "-h") => Some(render_help_err(spec, cmd, false)),
'V' if is_version_arg(spec, cmds, "-V") => Some(render_version_err(spec, false)),
_ => None,
}
}
fn reject_unknown_flag_if_asked(
spec: &Spec,
path: &[SpecCommand],
token: &str,
) -> Result<(), UsageErr> {
if !is_flag_like(token) {
return Ok(());
}
if effective_unknown_flags(spec, path) != UnknownFlags::Error {
return Ok(());
}
Err(UsageErr::InvalidFlag {
token: token.to_string(),
reason: "no such flag".to_string(),
span: (0, 0).into(),
input: token.to_string(),
})
}
fn effective_unknown_flags(spec: &Spec, path: &[SpecCommand]) -> UnknownFlags {
path.iter()
.rev()
.find_map(|cmd| cmd.unknown_flags)
.or(spec.unknown_flags)
.unwrap_or_default()
}
fn is_flag_like(token: &str) -> bool {
match token.strip_prefix('-') {
None | Some("") => false,
Some(_) => true,
}
}
fn is_negative_number(token: &str) -> bool {
token.strip_prefix('-').is_some_and(is_number)
}
fn record_scalar_flag_occurrence(
cmds: &[SpecCommand],
flag: &Arc<SpecFlag>,
command_level: usize,
bool_value: Option<bool>,
occurrences: &mut HashMap<(usize, usize), u8>,
errors: &mut Vec<UsageErr>,
) {
let strict = cmds
.get(command_level)
.is_some_and(|cmd| !cmd.args_override_self);
let collects_values = flag.var || flag.arg.as_ref().is_some_and(|arg| arg.var);
if !strict || flag.count || collects_values {
return;
}
let bit = match bool_value {
Some(false) if flag.negate.is_some() => 0b10,
_ => 0b01,
};
let key = (Arc::as_ptr(flag) as usize, command_level);
let seen = occurrences.entry(key).or_default();
if *seen & bit != 0 {
errors.push(UsageErr::DuplicateFlag(flag.name.clone()));
}
*seen |= bit;
}
fn is_command_word(token: &str) -> bool {
(!is_flag_like(token) || is_negative_number(token)) && token != "-"
}
fn declared_numeric_short(available_flags: &BTreeMap<String, Arc<SpecFlag>>, token: &str) -> bool {
token.len() == 2 && token.as_bytes()[1].is_ascii_digit() && available_flags.contains_key(token)
}
fn default_accepts_word(cmd: &SpecCommand, default_name: &str, token: &str) -> bool {
!is_negative_number(token)
|| cmd
.find_subcommand(default_name)
.and_then(|default| default.args.first())
.is_some_and(|arg| arg.allow_negative_numbers)
}
fn is_number(rest: &str) -> bool {
let (mantissa, exponent) = match rest.find(['e', 'E']) {
Some(at) => (&rest[..at], Some(&rest[at + 1..])),
None => (rest, None),
};
let mut seen_digit = false;
let mut seen_dot = false;
for c in mantissa.chars() {
match c {
'0'..='9' => seen_digit = true,
'.' if !seen_dot => seen_dot = true,
_ => return false,
}
}
if !seen_digit {
return false;
}
match exponent {
None => true,
Some(exp) => {
let digits = exp
.strip_prefix('+')
.or_else(|| exp.strip_prefix('-'))
.unwrap_or(exp);
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}
}
}
#[allow(clippy::too_many_arguments)]
fn bind_pending_flag_value(
spec: &Spec,
cmd: &SpecCommand,
errors: &mut Vec<UsageErr>,
flags: &mut IndexMap<Arc<SpecFlag>, ParseValue>,
flag_awaiting_value: &mut Vec<Arc<SpecFlag>>,
word: &mut String,
input: &mut VecDeque<Token>,
custom_env: Option<&HashMap<String, String>>,
trace: &mut Trace,
argv: usize,
attached: bool,
) -> miette::Result<bool> {
let collecting = flag_awaiting_value
.last()
.filter(|flag| flag.arg.as_ref().is_some_and(|arg| arg.var))
.cloned()
.map(|flag| {
let carried = flags.get(&flag).map(value_count).unwrap_or(0);
(flag, carried)
});
let mut bound = vec![];
let refused = drain_pending_flag_values(
spec,
cmd,
errors,
flags,
flag_awaiting_value,
word,
custom_env,
&mut bound,
)?;
for (flag, values) in bound {
trace.record(
argv,
TokenRole::Value {
flag,
values,
attached,
},
);
}
if refused {
return Ok(true);
}
let Some((flag, carried)) = collecting else {
return Ok(false);
};
collect_variadic_flag_values(
spec,
cmd,
errors,
flags,
flag_awaiting_value,
&flag,
carried,
input,
custom_env,
trace,
)
}
#[allow(clippy::too_many_arguments)]
fn collect_variadic_flag_values(
spec: &Spec,
cmd: &SpecCommand,
errors: &mut Vec<UsageErr>,
flags: &mut IndexMap<Arc<SpecFlag>, ParseValue>,
flag_awaiting_value: &mut Vec<Arc<SpecFlag>>,
flag: &Arc<SpecFlag>,
carried: usize,
input: &mut VecDeque<Token>,
custom_env: Option<&HashMap<String, String>>,
trace: &mut Trace,
) -> miette::Result<bool> {
let max = flag
.arg
.as_ref()
.and_then(|arg| arg.var_max)
.unwrap_or(usize::MAX);
while flags
.get(flag)
.map(value_count)
.unwrap_or(0)
.saturating_sub(carried)
< max
{
let Some(next) = input.front().map(|token| token.word.as_str()) else {
break;
};
if flag
.arg
.as_ref()
.and_then(|arg| arg.value_terminator.as_deref())
== Some(next)
{
let terminator = input.pop_front().unwrap();
trace.record(
terminator.argv,
TokenRole::ValueTerminator {
ends: flag.name.clone(),
},
);
break;
}
if next == "--"
|| (is_flag_like(next)
&& !(flag
.arg
.as_ref()
.is_some_and(|arg| arg.allow_negative_numbers)
&& is_negative_number(next)))
{
break;
}
let taken = input.pop_front().unwrap();
let argv = taken.argv;
let mut word = taken.word;
flag_awaiting_value.push(Arc::clone(flag));
let mut bound = vec![];
let refused = drain_pending_flag_values(
spec,
cmd,
errors,
flags,
flag_awaiting_value,
&mut word,
custom_env,
&mut bound,
)?;
for (flag, values) in bound {
trace.record(
argv,
TokenRole::Value {
flag,
values,
attached: false,
},
);
}
if refused {
return Ok(true);
}
}
let taken = flags
.get(flag)
.map(value_count)
.unwrap_or(0)
.saturating_sub(carried);
if let Some(min) = flag.arg.as_ref().and_then(|arg| arg.var_min) {
if taken < min {
errors.push(UsageErr::VarFlagTooFew {
name: flag.name.clone(),
min,
got: taken,
});
}
}
if taken > max {
errors.push(UsageErr::VarFlagTooMany {
name: flag.name.clone(),
max,
got: taken,
});
}
Ok(false)
}
fn value_count(value: &ParseValue) -> usize {
match value {
ParseValue::MultiString(values) => values.len(),
ParseValue::MultiBool(values) => values.len(),
_ => 1,
}
}
fn try_bind_default_missing(
flags: &mut IndexMap<Arc<SpecFlag>, ParseValue>,
flag_awaiting_value: &mut Vec<Arc<SpecFlag>>,
custom_env: Option<&HashMap<String, String>>,
origins: &mut IndexMap<Arc<SpecFlag>, Vec<ValueOrigin>>,
) -> miette::Result<bool> {
let Some(flag) = flag_awaiting_value.last() else {
return Ok(false);
};
let value = match flag.default_missing.clone() {
Some(value) => value,
None if flag.value_optional => {
let flag = flag_awaiting_value.pop().unwrap();
let variadic_value = flag.arg.as_ref().is_some_and(|arg| arg.var);
origins
.entry(Arc::clone(&flag))
.or_default()
.push(ValueOrigin::DefaultMissing);
if flag.var {
flags
.entry(flag)
.or_insert_with(|| ParseValue::MultiString(Vec::new()))
.try_as_multi_string_mut()
.unwrap()
.push(String::new());
} else if variadic_value {
flags
.entry(flag)
.or_insert_with(|| ParseValue::MultiString(Vec::new()));
} else {
flags.insert(flag, ParseValue::MultiString(Vec::new()));
}
return Ok(true);
}
None => return Ok(false),
};
if let Some(arg) = flag.arg.as_ref() {
validate_choice_value(
ChoiceTarget::option(flag),
&value,
arg.choices.as_ref(),
custom_env,
)?;
}
let flag = flag_awaiting_value.pop().unwrap();
origins
.entry(Arc::clone(&flag))
.or_default()
.push(ValueOrigin::DefaultMissing);
let collecting = flag.var || flag.arg.as_ref().is_some_and(|arg| arg.var);
if collecting {
let arr = flags
.entry(flag)
.or_insert_with(|| ParseValue::MultiString(vec![]))
.try_as_multi_string_mut()
.unwrap();
arr.push(value);
} else {
flags.insert(flag, ParseValue::String(value));
}
Ok(true)
}
#[allow(clippy::too_many_arguments)]
fn drain_pending_flag_values(
spec: &Spec,
cmd: &SpecCommand,
errors: &mut Vec<UsageErr>,
flags: &mut IndexMap<Arc<SpecFlag>, ParseValue>,
flag_awaiting_value: &mut Vec<Arc<SpecFlag>>,
word: &mut String,
custom_env: Option<&HashMap<String, String>>,
bound: &mut Vec<(Arc<SpecFlag>, Vec<String>)>,
) -> miette::Result<bool> {
while let Some(flag) = flag_awaiting_value.pop() {
let arg = flag.arg.as_ref().unwrap();
let parts: Vec<String> = match arg.delimiter {
Some(delimiter) => word.split(delimiter).map(str::to_string).collect(),
None => vec![std::mem::take(word)],
};
for part in &parts {
if validate_choices(
spec,
cmd,
errors,
ChoiceTarget::option(&flag),
part,
arg.choices.as_ref(),
custom_env,
)? {
return Ok(true);
}
}
word.clear();
bound.push((Arc::clone(&flag), parts.clone()));
if flag.var || arg.var {
let arr = flags
.entry(flag)
.or_insert_with(|| ParseValue::MultiString(vec![]))
.try_as_multi_string_mut()
.unwrap();
arr.extend(parts);
} else {
flags.insert(
flag,
ParseValue::String(parts.into_iter().next().unwrap_or_default()),
);
}
}
Ok(false)
}
fn choice_error(
target: ChoiceTarget<'_>,
value: &str,
choices: Option<&SpecChoices>,
custom_env: Option<&HashMap<String, String>>,
) -> Option<String> {
let choices = choices?;
if !choices.strict {
return None;
}
let values = choices.values_with_env(custom_env);
if choices.matches_with_env(value, custom_env) {
return None;
}
if let Some(env) = choices.env() {
if values.is_empty() {
return Some(format!(
"Invalid choice for {} {}: {value}, no choices resolved from env {env}",
target.kind, target.name,
));
}
}
Some(format!(
"Invalid choice for {} {}: {value}, expected one of {}",
target.kind,
target.name,
values.join(", ")
))
}
fn validate_choices(
spec: &Spec,
cmd: &SpecCommand,
errors: &mut Vec<UsageErr>,
target: ChoiceTarget<'_>,
value: &str,
choices: Option<&SpecChoices>,
custom_env: Option<&HashMap<String, String>>,
) -> miette::Result<bool> {
if is_help_arg(spec, cmd, value)
&& choices
.is_some_and(|choices| choices.strict && !choices.matches_with_env(value, custom_env))
{
errors.push(render_help_err(spec, cmd, value.len() > 2));
return Ok(true);
}
if let Some(err) = choice_error(target, value, choices, custom_env) {
bail!("{err}");
}
Ok(false)
}
fn validate_choice_value(
target: ChoiceTarget<'_>,
value: &str,
choices: Option<&SpecChoices>,
custom_env: Option<&HashMap<String, String>>,
) -> miette::Result<()> {
if let Some(err) = choice_error(target, value, choices, custom_env) {
bail!("{err}");
}
Ok(())
}
fn validate_choice_values(
target: ChoiceTarget<'_>,
values: &[String],
choices: Option<&SpecChoices>,
custom_env: Option<&HashMap<String, String>>,
) -> miette::Result<()> {
for value in values {
validate_choice_value(target, value, choices, custom_env)?;
}
Ok(())
}
fn record_stop(
out: &mut ParseOutput,
next_arg_idx: usize,
seen_double_dash: bool,
trace: &mut Trace,
unread: &VecDeque<Token>,
) {
out.next_arg = out.cmd.args.get(next_arg_idx).cloned().map(Arc::new);
out.double_dash_seen = seen_double_dash;
trace.close(unread);
out.tokens = std::mem::take(&mut trace.tokens);
}
fn report_double_dash_violation(
arg: &SpecArg,
errors: &mut Vec<UsageErr>,
violations: &mut HashSet<String>,
) {
if violations.insert(arg.name.clone()) {
errors.push(UsageErr::ArgRequiresDoubleDash(arg.name.clone()));
}
}
fn is_version_arg(spec: &Spec, cmds: &[SpecCommand], w: &str) -> bool {
(spec.version.is_some() || spec.long_version.is_some())
&& cmds.len() == 1
&& !spec.cmd.disable_version_flag
&& (w == "--version" || w == "-V")
}
fn is_help_arg(spec: &Spec, cmd: &SpecCommand, w: &str) -> bool {
spec.disable_help != Some(true)
&& (((w == "--help" || w == "-h" || w == "-?") && !cmd.disable_help_flag)
|| (w == "help" && !cmd.disable_help_subcommand && cmd.subcommands.is_empty()))
}
impl ParseOutput {
pub fn as_env(&self) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
for (flag, val) in &self.flags {
let key = format!("usage_{}", flag.name.to_snake_case());
let val = match val {
ParseValue::Bool(b) => if *b { "true" } else { "false" }.to_string(),
ParseValue::String(s) => s.clone(),
ParseValue::MultiBool(b) => b.iter().filter(|b| **b).count().to_string(),
ParseValue::MultiString(s) => shell_words::join(s),
};
env.insert(key, val);
}
for (arg, val) in &self.args {
let key = format!("usage_{}", arg.name.to_snake_case());
env.insert(key, val.to_string());
}
env
}
}
impl Display for ParseValue {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
ParseValue::Bool(b) => write!(f, "{b}"),
ParseValue::String(s) => write!(f, "{s}"),
ParseValue::MultiBool(b) => write!(f, "{}", b.iter().join(" ")),
ParseValue::MultiString(s) => write!(f, "{}", shell_words::join(s)),
}
}
}
fn render_token(token: &TokenBinding) -> String {
let roles = token.roles.iter().map(render_role).join(", ");
let synthesized = if token.synthesized { " (read as)" } else { "" };
format!("[{}] {}{synthesized}: {roles}", token.index, token.word)
}
fn render_role(role: &TokenRole) -> String {
match role {
TokenRole::Program => "program".to_string(),
TokenRole::Command { name } => format!("subcommand {name}"),
TokenRole::Flag {
flag,
spelling,
negated,
} => {
let negated = if *negated { ", negated" } else { "" };
format!("flag {} as {spelling}{negated}", flag.name)
}
TokenRole::Value {
flag,
values,
attached,
} => {
let attached = if *attached { ", attached" } else { "" };
format!("value of {} = {values:?}{attached}", flag.name)
}
TokenRole::Arg { arg, values } => format!("arg {} = {values:?}", arg.name),
TokenRole::Separator => "separator".to_string(),
TokenRole::Builtin { spelling } => format!("built-in {spelling}"),
TokenRole::ValueTerminator { ends } => format!("value terminator, ends {ends}"),
TokenRole::Restart => "restart".to_string(),
TokenRole::UnknownFlag { bound_as } => match bound_as {
Some(arg) => format!("unknown flag, bound as {}", arg.name),
None => "unknown flag".to_string(),
},
TokenRole::Refused { reason } => format!("refused: {reason}"),
TokenRole::External => "external".to_string(),
TokenRole::Unread => "unread".to_string(),
}
}
impl Debug for ParseOutput {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ParseOutput")
.field("cmds", &self.cmds.iter().map(|c| &c.name).join(" ").trim())
.field(
"args",
&self
.args
.iter()
.map(|(a, w)| format!("{}: {w}", a.name))
.collect_vec(),
)
.field(
"available_flags",
&self
.available_flags
.iter()
.map(|(f, w)| format!("{f}: {w}"))
.collect_vec(),
)
.field(
"flags",
&self
.flags
.iter()
.map(|(f, w)| format!("{}: {w}", f.name))
.collect_vec(),
)
.field("flag_awaiting_value", &self.flag_awaiting_value)
.field("errors", &self.errors)
.field("external", &self.external)
.field(
"tokens",
&self.tokens.iter().map(render_token).collect_vec(),
)
.field(
"origins",
&self
.flag_origins
.iter()
.map(|(f, o)| format!("{}: {o:?}", f.name))
.chain(
self.arg_origins
.iter()
.map(|(a, o)| format!("{}: {o:?}", a.name)),
)
.collect_vec(),
)
.field("overridden_flags", &self.overridden_flags)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SpecFlagAction;
fn input(words: &[&str]) -> Vec<String> {
words.iter().map(|word| (*word).to_string()).collect()
}
#[test]
fn a_declared_version_supplies_the_flag_the_help_page_lists() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\ncmd \"run\"\n"
.parse()
.unwrap();
for spelling in ["--version", "-V"] {
let err = parse(&spec, &input(&["ex", spelling]))
.expect_err("answering with a version ends the parse");
assert_eq!(err.to_string(), "1.2.3", "{spelling}");
}
}
#[test]
fn the_supplied_version_flag_is_the_roots_alone() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\ncmd \"run\"\n"
.parse()
.unwrap();
let err = parse(&spec, &input(&["ex", "run", "--version"])).unwrap_err();
assert_eq!(err.to_string(), "unexpected word: --version");
}
#[test]
fn no_declared_version_supplies_nothing() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\n".parse().unwrap();
for spelling in ["--version", "-V"] {
let err = parse(&spec, &input(&["ex", spelling])).unwrap_err();
assert_eq!(err.to_string(), format!("unexpected word: {spelling}"));
}
}
#[test]
fn disable_version_flag_removes_the_supplied_spellings() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\ndisable_version_flag #true\n"
.parse()
.unwrap();
for spelling in ["--version", "-V"] {
let err = parse(&spec, &input(&["ex", spelling])).unwrap_err();
assert_eq!(err.to_string(), format!("unexpected word: {spelling}"));
}
}
#[test]
fn a_spelling_the_spec_spends_elsewhere_keeps_its_meaning() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nflag \"-V --verbose\"\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "-V"])).expect("-V is the CLI's own flag");
assert_eq!(out.flags.len(), 1);
let err = parse(&spec, &input(&["ex", "--version"])).unwrap_err();
assert_eq!(err.to_string(), "1.2.3");
}
#[test]
fn the_supplied_spellings_split_the_two_version_texts() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nlong_version \"1.2.3 (abcdef)\"\n"
.parse()
.unwrap();
assert_eq!(
parse(&spec, &input(&["ex", "--version"]))
.unwrap_err()
.to_string(),
"1.2.3 (abcdef)"
);
assert_eq!(
parse(&spec, &input(&["ex", "-V"])).unwrap_err().to_string(),
"1.2.3"
);
}
#[test]
fn a_supplied_short_is_a_letter_a_bundle_may_contain() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nflag \"-v --verbose\"\ncmd \"run\"\n"
.parse()
.unwrap();
for token in ["-vh", "-hv"] {
let err = parse(&spec, &input(&["ex", token])).expect_err("help ends the parse");
assert!(err.to_string().starts_with("ex 1.2.3"), "{token}: {err}");
}
for token in ["-vV", "-Vv"] {
let err = parse(&spec, &input(&["ex", token])).expect_err("a version ends it too");
assert_eq!(err.to_string(), "1.2.3", "{token}");
}
}
#[test]
fn a_bundled_help_letter_asks_for_the_short_page() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-v --verbose\" help=\"Be loud\" {\n long_help \"Be loud, and say so at length.\"\n}\n"
.parse()
.unwrap();
let short = parse(&spec, &input(&["ex", "-vh"]))
.unwrap_err()
.to_string();
let long = parse(&spec, &input(&["ex", "--help"]))
.unwrap_err()
.to_string();
assert!(short.contains("Be loud"), "{short}");
assert!(!short.contains("at length"), "{short}");
assert!(long.contains("at length"), "{long}");
}
#[test]
fn the_bundled_version_letter_is_the_roots_alone() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nflag \"-v --verbose\" global=#true\ncmd \"run\"\n"
.parse()
.unwrap();
let err = parse(&spec, &input(&["ex", "run", "-vV"])).unwrap_err();
assert_eq!(err.to_string(), "unexpected word: -vV");
}
#[test]
fn a_declared_letter_keeps_its_meaning_inside_a_bundle() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nversion \"1.2.3\"\nflag \"-v --verbose\"\nflag \"-h --host <host>\"\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "-vhlocal"])).expect("a bundle and its value");
assert_eq!(out.flags.len(), 2);
assert!(out
.flags
.iter()
.any(|(flag, value)| flag.name == "host" && value.to_string() == "local"));
}
#[test]
fn disabling_help_takes_the_letter_back_out_of_the_bundle() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\ndisable_help_flag #true\nflag \"-v --verbose\"\n"
.parse()
.unwrap();
let err = parse(&spec, &input(&["ex", "-vh"])).unwrap_err();
assert_eq!(err.to_string(), "unexpected word: -vh");
}
#[test]
fn a_letter_nothing_supplies_still_refuses_the_whole_bundle() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-a --all\"\narg \"[file]\"\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "-az"])).expect("it falls through to the argument");
assert!(out.flags.is_empty(), "{:?}", out.flags);
assert_eq!(out.args.len(), 1);
}
fn spec_with_arg(arg: SpecArg) -> Spec {
let cmd = SpecCommand::builder().name("test").arg(arg).build();
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
fn spec_with_flag(flag: SpecFlag) -> Spec {
let cmd = SpecCommand::builder().name("test").flag(flag).build();
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
fn parse_with_env(
spec: &Spec,
words: &[&str],
env: &[(&str, &str)],
) -> Result<ParseOutput, miette::Error> {
let env = env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
Parser::new(spec).with_env(env).parse(&input(words))
}
fn first_string_value(parsed: &ParseOutput) -> &str {
if let Some(ParseValue::String(value)) = parsed.args.values().next() {
return value;
}
if let Some(ParseValue::String(value)) = parsed.flags.values().next() {
return value;
}
panic!("expected first parsed value to be ParseValue::String");
}
#[test]
fn custom_environment_parser_dispatches_executable_views() {
let spec: Spec = r#"
bin "ex"
view "runner" root="run"
cmd "run" {
flag "--token <token>" env="TOKEN"
}
"#
.parse()
.unwrap();
let parsed = Parser::new(&spec)
.with_env([("TOKEN".to_string(), "secret".to_string())].into())
.parse(&input(&["runner"]))
.unwrap();
assert_eq!(parsed.cmd.name, "runner");
assert!(parsed.flags.iter().any(|(flag, value)| flag.name == "token"
&& matches!(value, ParseValue::String(value) if value == "secret")));
}
#[test]
fn an_executable_view_keeps_the_hosts_version_action() {
let spec: Spec = r#"
bin "ex"
version "1.2.3"
flag "-V --version" action="version"
flag "--verbose" global=#true
view "runner" root="run" globals=#true
cmd "run"
"#
.parse()
.unwrap();
let error = Parser::new(&spec)
.parse(&input(&["runner", "--version"]))
.expect_err("the host version action should answer before view projection");
assert_eq!(error.to_string(), "1.2.3");
let error = Parser::new(&spec)
.parse(&input(&["runner", "--verbose", "--version"]))
.expect_err("the host version action should remain after a carried global");
assert_eq!(error.to_string(), "1.2.3");
}
fn flag_string_value<'a>(parsed: &'a ParseOutput, name: &str) -> &'a str {
let flag = parsed
.flags
.keys()
.find(|flag| flag.name == name)
.unwrap_or_else(|| panic!("expected flag {name}"));
let value = parsed
.flags
.get(flag)
.unwrap_or_else(|| panic!("expected value for flag {name}"));
match value {
ParseValue::String(value) => value,
_ => panic!("expected flag {name} to be ParseValue::String"),
}
}
fn assert_parse_err(result: Result<ParseOutput, miette::Error>, expected: &str) {
let err = result.expect_err("expected parser error");
assert_eq!(format!("{err}"), expected);
}
#[test]
fn a_short_version_action_falls_back_to_the_long_version() {
let flag = SpecFlag::builder()
.short('R')
.action(SpecFlagAction::Version)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
long_version: Some("1.2.3\ncommit abc123".to_string()),
..Default::default()
};
let UsageErr::Version(version) = render_action_err(&spec, &spec.cmd, &flag, "-R") else {
panic!("expected version action")
};
assert_eq!(version, "1.2.3\ncommit abc123");
}
#[cfg(feature = "unstable_choices_env")]
fn spec_arg_choices_env(key: &str) -> Spec {
spec_with_arg(
SpecArg::builder()
.name("env")
.choices_env(key)
.required(false)
.build(),
)
}
#[cfg(feature = "unstable_choices_env")]
fn spec_flag_choices_env(key: &str) -> Spec {
spec_with_flag(
SpecFlag::builder()
.long("env")
.arg(SpecArg::builder().name("env").choices_env(key).build())
.build(),
)
}
#[test]
fn test_parse() {
let cmd = SpecCommand::builder()
.name("test")
.arg(SpecArg::builder().name("arg").build())
.flag(SpecFlag::builder().long("flag").build())
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string(), "arg1".to_string(), "--flag".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 1);
assert_eq!(parsed.cmds[0].name, "test");
assert_eq!(parsed.args.len(), 1);
assert_eq!(parsed.flags.len(), 1);
assert_eq!(parsed.available_flags.len(), 1);
}
#[test]
fn test_flag_overrides_last_occurrence_wins() {
let spec: Spec = r#"
flag "--stdin" default=#true
flag "--file <file>" overrides="--stdin"
"#
.parse()
.unwrap();
let file_wins = parse(&spec, &input(&["test", "--stdin", "--file", "input.txt"])).unwrap();
assert_eq!(file_wins.flags.len(), 1);
assert_eq!(flag_string_value(&file_wins, "file"), "input.txt");
assert!(!file_wins.flags.keys().any(|flag| flag.name == "stdin"));
let stdin_wins = parse(&spec, &input(&["test", "--file", "input.txt", "--stdin"])).unwrap();
assert_eq!(stdin_wins.flags.len(), 1);
assert!(stdin_wins.flags.keys().any(|flag| flag.name == "stdin"));
assert!(!stdin_wins.flags.keys().any(|flag| flag.name == "file"));
}
#[test]
fn test_flag_override_clears_pending_value() {
let spec: Spec = r#"
flag "--file <file>" overrides="--stdin"
flag "--stdin"
arg "[input]"
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--file", "--stdin", "input.txt"])).unwrap();
assert_eq!(parsed.flags.len(), 1);
assert!(parsed.flags.keys().any(|flag| flag.name == "stdin"));
assert_eq!(first_string_value(&parsed), "input.txt");
}
#[cfg(unix)]
#[test]
fn a_mount_on_the_root_discovers_subcommands() {
let spec: Spec = r#"
name "ex"
bin "ex"
cmd "declared"
mount run="echo 'cmd \"discovered\"'"
"#
.parse()
.unwrap();
let out = parse(&spec, &["ex".to_string(), "discovered".to_string()]).unwrap();
assert_eq!(out.cmd.name, "discovered");
}
#[test]
fn injected_mount_outputs_are_complete_and_never_fall_back_to_processes() {
let spec: Spec = r#"
name "ex"
bin "ex"
mount run="this command must never run"
cmd "declared"
"#
.parse()
.unwrap();
Parser::new(&spec)
.with_mount_outputs(HashMap::new())
.parse(&input(&["ex", "declared"]))
.expect("a declared command does not resolve the mount");
let error = Parser::new(&spec)
.with_mount_outputs(HashMap::new())
.parse(&input(&["ex", "discovered"]))
.unwrap_err();
assert!(
error
.to_string()
.contains("No injected output was provided for mount command"),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn completion_sees_root_mounted_commands_with_nothing_typed() {
let spec: Spec = r#"
name "ex"
bin "ex"
cmd "declared"
mount run="echo 'cmd \"discovered\"'"
"#
.parse()
.unwrap();
let out = parse_partial(&spec, &["ex".to_string()]).unwrap();
assert!(
out.cmd.subcommands.contains_key("discovered"),
"a completion should see mounted commands; got {:?}",
out.cmd.subcommands.keys().collect::<Vec<_>>()
);
}
#[cfg(unix)]
#[test]
fn completion_and_execution_agree_about_discovery() {
let spec: Spec = r#"
name "ex"
bin "ex"
default_subcommand "run"
cmd "run" {
arg "<task>"
}
mount run="exit 1"
"#
.parse()
.unwrap();
let out = parse_partial(&spec, &["ex".to_string()]).unwrap();
assert!(
!out.cmd.subcommands.contains_key("discovered"),
"a completion must not offer what execution will not route"
);
let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap();
assert_eq!(out.cmd.name, "run");
}
#[cfg(unix)]
#[test]
fn a_default_subcommand_outranks_discovery() {
let spec: Spec = r#"
name "ex"
bin "ex"
default_subcommand "run"
cmd "run" {
arg "<task>"
}
mount run="exit 1"
"#
.parse()
.unwrap();
let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap();
assert_eq!(out.cmd.name, "run");
}
#[cfg(unix)]
#[test]
fn a_mount_may_ask_to_outrank_the_default() {
let spec: Spec = r#"
name "ex"
bin "ex"
default_subcommand "run"
cmd "run" {
arg "<task>"
}
mount run="echo 'cmd \"discovered\"'" overrides_default=#true
"#
.parse()
.unwrap();
let out = parse(&spec, &["ex".to_string(), "discovered".to_string()]).unwrap();
assert_eq!(out.cmd.name, "discovered");
let out = parse(&spec, &["ex".to_string(), "mytask".to_string()]).unwrap();
assert_eq!(out.cmd.name, "run");
}
#[cfg(unix)]
#[test]
fn a_flag_does_not_run_the_mount() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--verbose"
cmd "declared"
mount run="exit 1"
"#
.parse()
.unwrap();
let out = parse(&spec, &["ex".to_string(), "--verbose".to_string()]).unwrap();
assert_eq!(out.cmd.name, "ex");
}
#[cfg(unix)]
#[test]
fn a_declared_subcommand_does_not_run_the_mount() {
let spec: Spec = r#"
name "ex"
bin "ex"
cmd "declared"
mount run="exit 1"
"#
.parse()
.unwrap();
let out = parse(&spec, &["ex".to_string(), "declared".to_string()]).unwrap();
assert_eq!(out.cmd.name, "declared");
}
#[test]
fn a_root_mount_survives_being_written_out() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nmount run=\"ex plugins --usage\"\n"
.parse()
.unwrap();
assert_eq!(spec.cmd.mounts.len(), 1);
let reparsed: Spec = spec.to_string().parse().unwrap();
assert_eq!(reparsed.cmd.mounts.len(), 1, "written:\n{spec}");
assert_eq!(reparsed.cmd.mounts[0].run, "ex plugins --usage");
}
#[test]
fn test_mount_prefix_applies_flag_overrides() {
let stdin = Arc::new(
SpecFlag::builder()
.name("stdin")
.long("stdin")
.global(true)
.build(),
);
let file = Arc::new(
SpecFlag::builder()
.name("file")
.long("file")
.arg(SpecArg::builder().name("file").build())
.global(true)
.overrides_with(vec!["--stdin".to_string()])
.build(),
);
let mut prefix_flags = vec![(stdin, vec!["--stdin".to_string()])];
apply_prefix_flag_overrides(&mut prefix_flags, Arc::clone(&file));
prefix_flags.push((file, vec!["--file".to_string(), "input.txt".to_string()]));
assert_eq!(mount_prefix_words(&prefix_flags), ["--file", "input.txt"]);
}
#[test]
fn test_flag_override_suppresses_env_value() {
let spec: Spec = r#"
flag "--stdin" env="USE_STDIN"
flag "--file <file>" overrides="--stdin"
"#
.parse()
.unwrap();
let parsed = parse_with_env(
&spec,
&["test", "--file", "input.txt"],
&[("USE_STDIN", "true")],
)
.unwrap();
assert_eq!(parsed.flags.len(), 1);
assert_eq!(flag_string_value(&parsed, "file"), "input.txt");
}
#[test]
fn test_flag_override_suppresses_required_check() {
let spec: Spec = r#"
flag "--stdin" required=#true
flag "--file <file>" overrides="--stdin"
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--file", "input.txt"])).unwrap();
assert_eq!(parsed.flags.len(), 1);
assert_eq!(flag_string_value(&parsed, "file"), "input.txt");
}
#[test]
fn test_flag_required_if() {
let spec: Spec = r#"
flag "--dir <dir>"
flag "--file <file>" required_if="--dir"
"#
.parse()
.unwrap();
parse(&spec, &input(&["test"])).unwrap();
assert_parse_err(
parse(&spec, &input(&["test", "--dir", "src"])),
"Missing required flag: --file <file>",
);
parse(
&spec,
&input(&["test", "--dir", "src", "--file", "input.txt"]),
)
.unwrap();
}
#[test]
fn test_flag_required_unless() {
let spec: Spec = r#"
flag "--stdin"
flag "--file <file>" required_unless="--stdin"
"#
.parse()
.unwrap();
assert_parse_err(
parse(&spec, &input(&["test"])),
"Missing required flag: --file <file>",
);
parse(&spec, &input(&["test", "--stdin"])).unwrap();
parse(&spec, &input(&["test", "--file", "input.txt"])).unwrap();
}
#[test]
fn complete_required_relationship_truth_tables() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--mode <mode>"
flag "--scope <scope>"
flag "--token <token>" {
required_if_eq "--mode" "remote"
}
flag "--approval <approval>" {
required_if_eq_all "--mode" "remote" "--scope" "global"
}
flag "--input <input>" {
required_unless "--stdin" "--file"
}
flag "--checksum <checksum>" {
required_unless_all "--stdin" "--file"
}
flag "--stdin"
flag "--file <file>"
arg "[request]" {
requires "--mode" "--scope"
}
"#
.parse()
.unwrap();
let parse_args = |args: &[&str]| {
parse(
&spec,
&args
.iter()
.map(|arg| (*arg).to_string())
.collect::<Vec<_>>(),
)
};
assert!(parse_args(&["ex", "--mode", "remote", "--stdin"]).is_err());
assert!(parse_args(&[
"ex", "--mode", "remote", "--token", "secret", "--scope", "global", "--stdin",
])
.is_err());
parse_args(&[
"ex",
"--mode",
"remote",
"--token",
"secret",
"--scope",
"global",
"--approval",
"yes",
"--stdin",
"--file",
"in",
])
.unwrap();
parse_args(&[
"ex",
"--mode",
"local",
"--scope",
"project",
"--stdin",
"--checksum",
"sum",
"request.json",
])
.unwrap();
let reparsed: Spec = spec.to_string().parse().unwrap();
assert_eq!(reparsed.cmd.flags[2].required_if_eq.len(), 1);
assert_eq!(reparsed.cmd.flags[3].required_if_eq_all.len(), 2);
assert_eq!(reparsed.cmd.flags[5].required_unless_all.len(), 2);
assert_eq!(reparsed.cmd.args[0].requires.len(), 2);
}
#[test]
fn test_conditional_requirements_treat_env_as_explicit() {
let spec: Spec = r#"
flag "--dir <dir>" env="INPUT_DIR"
flag "--stdin" env="USE_STDIN"
flag "--file <file>" required_if="--dir" required_unless="--stdin"
"#
.parse()
.unwrap();
assert_parse_err(
parse_with_env(&spec, &["test"], &[("INPUT_DIR", "src")]),
"Missing required flag: --file <file>",
);
parse_with_env(&spec, &["test"], &[("USE_STDIN", "true")]).unwrap();
}
#[test]
fn test_custom_env_does_not_fall_back_to_process_env() {
assert!(std::env::var("PATH").is_ok());
let spec: Spec = r#"flag "--file <file>" env="PATH" required=#true"#.parse().unwrap();
assert_parse_err(
parse_with_env(&spec, &["test"], &[]),
"Missing required flag: --file <file>",
);
}
#[test]
fn test_conditional_requirements_ignore_defaults_on_condition_flags() {
let spec: Spec = r#"
flag "--dir <dir>" default="src"
flag "--file <file>" required_if="--dir"
"#
.parse()
.unwrap();
parse(&spec, &input(&["test"])).unwrap();
}
#[test]
fn test_conditional_requirements_see_overridden_flags_as_absent() {
let spec: Spec = r#"
flag "--stdin"
flag "--dir <dir>" overrides="--stdin"
flag "--file <file>" required_unless="--stdin"
"#
.parse()
.unwrap();
assert_parse_err(
parse(&spec, &input(&["test", "--stdin", "--dir", "src"])),
"Missing required flag: --file <file>",
);
}
#[test]
fn short_flag_is_one_character_not_one_byte() {
let spec = spec_with_flag(
SpecFlag::builder()
.short('磨')
.long("polish")
.arg(SpecArg::builder().name("opt").build())
.build(),
);
let attached = Parser::new(&spec)
.parse(&input(&["test", "-磨VALUE"]))
.unwrap();
assert_eq!(flag_string_value(&attached, "polish"), "VALUE");
let detached = Parser::new(&spec)
.parse(&input(&["test", "-磨", "V"]))
.unwrap();
assert_eq!(flag_string_value(&detached, "polish"), "V");
}
#[test]
fn test_as_env() {
let cmd = SpecCommand::builder()
.name("test")
.arg(SpecArg::builder().name("arg").build())
.flag(SpecFlag::builder().long("flag").build())
.flag(
SpecFlag::builder()
.long("force")
.negate("--no-force")
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"--flag".to_string(),
"--no-force".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
let env = parsed.as_env();
assert_eq!(env.len(), 2);
assert_eq!(env.get("usage_flag"), Some(&"true".to_string()));
assert_eq!(env.get("usage_force"), Some(&"false".to_string()));
}
#[test]
fn test_arg_env_var() {
let cmd = SpecCommand::builder()
.name("test")
.arg(
SpecArg::builder()
.name("input")
.env("TEST_ARG_INPUT")
.required(true)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
std::env::set_var("TEST_ARG_INPUT", "test_file.txt");
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let arg = parsed.args.keys().next().unwrap();
assert_eq!(arg.name, "input");
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "test_file.txt");
std::env::remove_var("TEST_ARG_INPUT");
}
#[test]
fn test_flag_env_var_with_arg() {
let cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.long("output")
.env("TEST_FLAG_OUTPUT")
.arg(SpecArg::builder().name("file").build())
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
std::env::set_var("TEST_FLAG_OUTPUT", "output.txt");
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.flags.len(), 1);
let flag = parsed.flags.keys().next().unwrap();
assert_eq!(flag.name, "output");
let value = parsed.flags.values().next().unwrap();
assert_eq!(value.to_string(), "output.txt");
std::env::remove_var("TEST_FLAG_OUTPUT");
}
#[test]
fn test_flag_env_var_boolean() {
let cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.long("verbose")
.env("TEST_FLAG_VERBOSE")
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
std::env::set_var("TEST_FLAG_VERBOSE", "true");
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.flags.len(), 1);
let flag = parsed.flags.keys().next().unwrap();
assert_eq!(flag.name, "verbose");
let value = parsed.flags.values().next().unwrap();
assert_eq!(value.to_string(), "true");
std::env::remove_var("TEST_FLAG_VERBOSE");
}
#[test]
fn test_env_var_precedence() {
let cmd = SpecCommand::builder()
.name("test")
.arg(
SpecArg::builder()
.name("input")
.env("TEST_PRECEDENCE_INPUT")
.required(true)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
std::env::set_var("TEST_PRECEDENCE_INPUT", "env_file.txt");
let input = vec!["test".to_string(), "cli_file.txt".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "cli_file.txt");
std::env::remove_var("TEST_PRECEDENCE_INPUT");
}
#[test]
fn test_flag_var_true_with_single_default() {
let cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.long("foo")
.var(true)
.arg(SpecArg::builder().name("foo").build())
.default_value("bar")
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.flags.len(), 1);
let flag = parsed.flags.keys().next().unwrap();
assert_eq!(flag.name, "foo");
let value = parsed.flags.values().next().unwrap();
match value {
ParseValue::MultiString(v) => {
assert_eq!(v.len(), 1);
assert_eq!(v[0], "bar");
}
_ => panic!("Expected MultiString, got {:?}", value),
}
}
#[test]
fn test_flag_var_true_with_multiple_defaults() {
let cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.long("foo")
.var(true)
.arg(SpecArg::builder().name("foo").build())
.default_values(["xyz", "bar"])
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.flags.len(), 1);
let value = parsed.flags.values().next().unwrap();
match value {
ParseValue::MultiString(v) => {
assert_eq!(v.len(), 2);
assert_eq!(v[0], "xyz");
assert_eq!(v[1], "bar");
}
_ => panic!("Expected MultiString, got {:?}", value),
}
}
#[test]
fn test_flag_var_false_with_default_remains_string() {
let cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.long("foo")
.var(false) .arg(SpecArg::builder().name("foo").build())
.default_value("bar")
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.flags.len(), 1);
let value = parsed.flags.values().next().unwrap();
match value {
ParseValue::String(s) => {
assert_eq!(s, "bar");
}
_ => panic!("Expected String, got {:?}", value),
}
}
#[test]
fn test_arg_var_true_with_single_default() {
let cmd = SpecCommand::builder()
.name("test")
.arg(
SpecArg::builder()
.name("files")
.var(true)
.default_value("default.txt")
.required(false)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
match value {
ParseValue::MultiString(v) => {
assert_eq!(v.len(), 1);
assert_eq!(v[0], "default.txt");
}
_ => panic!("Expected MultiString, got {:?}", value),
}
}
#[test]
fn test_arg_var_true_with_multiple_defaults() {
let cmd = SpecCommand::builder()
.name("test")
.arg(
SpecArg::builder()
.name("files")
.var(true)
.default_values(["file1.txt", "file2.txt"])
.required(false)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
match value {
ParseValue::MultiString(v) => {
assert_eq!(v.len(), 2);
assert_eq!(v[0], "file1.txt");
assert_eq!(v[1], "file2.txt");
}
_ => panic!("Expected MultiString, got {:?}", value),
}
}
#[test]
fn test_arg_var_false_with_default_remains_string() {
let cmd = SpecCommand::builder()
.name("test")
.arg(
SpecArg::builder()
.name("file")
.var(false)
.default_value("default.txt")
.required(false)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec!["test".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
match value {
ParseValue::String(s) => {
assert_eq!(s, "default.txt");
}
_ => panic!("Expected String, got {:?}", value),
}
}
#[test]
fn test_scalar_defaults_validate_only_first_default_choice() {
let specs = [
spec_with_arg(
SpecArg::builder()
.name("env")
.var(false)
.default_values(["dev", "prod"])
.choices(["dev"])
.required(false)
.build(),
),
spec_with_flag(
SpecFlag::builder()
.long("env")
.arg(
SpecArg::builder()
.name("env")
.default_values(["dev", "prod"])
.choices(["dev"])
.build(),
)
.build(),
),
];
for spec in specs {
let parsed = parse(&spec, &input(&["test"])).unwrap();
assert_eq!(first_string_value(&parsed), "dev");
}
}
#[test]
fn a_delimiter_turns_one_word_into_several_values() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--tags <tag>\" var=#true delimiter=\",\"\narg \"[files]...\" var=#true delimiter=\":\"\n"
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["ex", "--tags", "a,b,c", "x:y"])).unwrap();
let multi = |value: &ParseValue| match value {
ParseValue::MultiString(values) => values.clone(),
other => panic!("expected several values, got {other:?}"),
};
let tags = parsed
.flags
.iter()
.find(|(f, _)| f.name == "tags")
.map(|(_, v)| v)
.unwrap();
assert_eq!(multi(tags), vec!["a", "b", "c"]);
assert_eq!(multi(parsed.args.values().next().unwrap()), vec!["x", "y"]);
}
#[test]
fn a_positional_splits_before_its_choices_are_asked() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\narg \"[paths]...\" var=#true delimiter=\":\" {\n choices \"src\" \"docs\"\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "src:docs"])).expect("both halves are choices");
let err = parse(&spec, &input(&["ex", "src:nowhere"])).unwrap_err();
let message = err.to_string();
assert!(message.contains("nowhere"), "{message}");
assert!(
!message.contains("src:nowhere"),
"the bad half should be named, not the whole word: {message}"
);
}
#[test]
fn a_split_value_is_counted_and_judged_as_values() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--env <e>\" var=#true delimiter=\",\" var_max=2 {\n choices \"dev\" \"prod\"\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--env", "dev,prod"])).expect("two values, both allowed");
let err = parse(&spec, &input(&["ex", "--env", "dev,staging"])).unwrap_err();
assert!(err.to_string().contains("staging"), "{err}");
assert!(
parse(&spec, &input(&["ex", "--env", "dev,prod,dev"])).is_err(),
"three values should breach var_max=2"
);
}
#[test]
fn a_split_bound_counts_one_occurrence_at_a_time() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--include <pattern>...\" delimiter=\",\" {\n arg \"<pattern>...\" var=#true var_max=2\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--include", "a,b"])).expect("exactly the bound is fine");
assert!(
parse(&spec, &input(&["ex", "--include", "a,b,c"])).is_err(),
"three values out of one word is still three values"
);
parse(
&spec,
&input(&["ex", "--include", "a,b", "--include", "c,d"]),
)
.expect("two per occurrence, twice, is within the bound");
}
#[test]
fn a_nested_minimum_is_checked_once_per_flag_occurrence() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--pair <value>...\" {\n arg \"<value>...\" var=#true var_min=2 var_max=2\n}\n"
.parse()
.unwrap();
parse(
&spec,
&input(&["ex", "--pair", "a", "b", "--pair", "c", "d"]),
)
.expect("each occurrence satisfies the bound independently");
let error = parse(&spec, &input(&["ex", "--pair", "a", "--pair", "b", "c"])).unwrap_err();
assert!(
error
.to_string()
.contains("requires at least 2 value(s), got 1"),
"{error:?}"
);
}
#[test]
fn an_exclusive_flag_has_to_be_alone() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--verbose\"\narg \"[target]\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--dump"])).expect("alone is the point");
let err = parse(&spec, &input(&["ex", "--dump", "--verbose"])).unwrap_err();
assert!(err.to_string().contains("on its own"), "{err}");
let err = parse(&spec, &input(&["ex", "--dump", "t"])).unwrap_err();
assert!(err.to_string().contains("on its own"), "{err}");
parse(&spec, &input(&["ex", "--verbose", "t"])).expect("without it, nothing changes");
}
#[test]
fn an_exclusive_flag_is_not_disturbed_by_a_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--jobs <n>\" default=\"4\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--dump"])).expect("a default is nobody saying anything");
assert!(parse(&spec, &input(&["ex", "--dump", "--jobs", "8"])).is_err());
}
#[test]
fn an_exclusive_flag_bypasses_required_siblings() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out <path>\" required=#true\narg \"<target>\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--dump"]))
.expect("exclusive is the command's requiredness escape");
assert!(parse(
&spec,
&input(&["ex", "--dump", "--out", "somewhere", "target"])
)
.is_err());
}
#[test]
fn an_environment_value_counts_for_an_exclusive_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--dump\" exclusive=#true\nflag \"--out <path>\" env=\"EX_OUT\"\n"
.parse()
.unwrap();
assert!(parse_with_env(&spec, &["ex", "--dump"], &[("EX_OUT", "somewhere")]).is_err());
parse_with_env(&spec, &["ex", "--dump"], &[]).expect("without the value it is alone");
}
#[test]
fn a_selected_subcommand_counts_for_an_ancestor_exclusive_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--version\" global=#true exclusive=#true\ncmd \"run\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--version"])).expect("alone is allowed");
assert!(parse(&spec, &input(&["ex", "--version", "run"])).is_err());
}
#[test]
fn a_child_exclusive_flag_is_not_mistaken_for_a_same_named_parent_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("the child flag is alone within the child command");
assert!(
parse(&spec, &input(&["ex", "--clean", "run"])).is_err(),
"the parent flag still conflicts with selecting the child"
);
}
#[test]
fn a_child_local_exclusive_redeclaration_belongs_to_the_child() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("the child-local exclusive flag is alone inside the child command");
assert!(
parse(&spec, &input(&["ex", "--clean", "run"])).is_err(),
"the ancestor spelling still conflicts with selecting the child"
);
}
#[test]
fn a_same_named_parent_flag_is_company_for_a_child_exclusive_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" global=#true exclusive=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"])).expect("the child exclusive flag is alone");
assert!(
parse(&spec, &input(&["ex", "--clean", "run", "--clean"])).is_err(),
"the distinct parent declaration is still company despite sharing a name"
);
}
#[test]
fn a_local_child_redeclaration_keeps_its_exclusivity_when_merged() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("the child exclusive flag is valid alone");
assert!(
parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(),
"merging with the inherited global must not discard child exclusivity"
);
}
#[test]
fn an_orphan_parent_alias_does_not_disown_a_child_local_exclusive_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("the typed long form belongs to the child declaration");
assert!(
parse(&spec, &input(&["ex", "run", "-c"])).is_err(),
"the inherited short form still belongs to the ancestor"
);
assert!(
parse(&spec, &input(&["ex", "run", "-c", "--clean"])).is_err(),
"a child spelling cannot mask the ancestor-exclusive occurrence on the same merged flag"
);
}
#[test]
fn an_inherited_alias_keeps_its_ancestor_exclusivity() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" global=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("the child's spelling does not activate the orphan ancestor alias");
assert!(
parse(&spec, &input(&["ex", "run", "-c"])).is_err(),
"the inherited short alias still belongs to the ancestor exclusive flag"
);
}
#[test]
fn an_inherited_negated_alias_keeps_its_ancestor_exclusivity() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" negate=\"--no-clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"-c --clean\" global=#true\n}\n"
.parse()
.unwrap();
assert!(
parse(&spec, &input(&["ex", "run", "--no-clean"])).is_err(),
"the inherited negated alias still belongs to the ancestor exclusive flag"
);
}
#[test]
fn a_colliding_alias_does_not_disown_the_child_from_the_rest() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\nflag \"-c --config <f>\" global=#true\ncmd \"run\" {\n flag \"-c --clean\" exclusive=#true\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"])).expect("alone is allowed");
assert!(
parse(&spec, &input(&["ex", "run", "--clean", "--verbose"])).is_err(),
"one unrelated alias collision cannot disown the child from its own flag"
);
}
#[test]
fn a_local_child_declaration_is_not_in_scope_before_the_subcommand() {
let quiet: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
parse(&quiet, &input(&["ex", "--clean", "run", "--verbose"]))
.expect("the ancestor owns this occurrence, and it is not exclusive");
assert!(
parse(&quiet, &input(&["ex", "run", "--clean", "--verbose"])).is_err(),
"after the subcommand word the child's declaration is in scope, and it is exclusive"
);
let loud: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n}\n"
.parse()
.unwrap();
assert!(
parse(&loud, &input(&["ex", "--clean", "run"])).is_err(),
"the same rule, with an exclusive ancestor: selecting the child is company for it"
);
}
#[test]
fn an_orphan_ancestor_alias_keeps_its_exclusivity_past_a_plain_child_redeclaration() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
assert!(
parse(&spec, &input(&["ex", "run", "-c"])).is_err(),
"the orphan ancestor alias is still the ancestor's exclusive flag"
);
parse(&spec, &input(&["ex", "run", "--clean", "--verbose"]))
.expect("the child's own spelling drops the exclusivity the child did not restate");
}
#[test]
fn a_child_spelling_carries_its_exclusivity_even_beside_an_ancestor_spelling() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
assert!(
parse(&spec, &input(&["ex", "run", "-c", "--clean", "--verbose"])).is_err(),
"the child spelling is exclusive whatever it was typed beside"
);
parse(&spec, &input(&["ex", "run", "-c", "--verbose"]))
.expect("the ancestor's own spelling was never exclusive");
}
#[test]
fn an_environment_value_takes_the_exclusivity_of_the_declaration_in_scope() {
let added: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
assert!(
parse_with_env(&added, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")]).is_err(),
"the child added exclusivity the environment value has to honor"
);
let dropped: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-c --clean\" global=#true exclusive=#true env=\"EX_CLEAN\"\ncmd \"run\" {\n flag \"--clean\"\n flag \"--verbose\"\n}\n"
.parse()
.unwrap();
parse_with_env(&dropped, &["ex", "run", "--verbose"], &[("EX_CLEAN", "1")])
.expect("the child dropped the exclusivity, and the environment value follows it");
}
#[test]
fn a_merged_child_exclusive_flag_still_escapes_requiredness() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--clean\" global=#true\ncmd \"run\" {\n flag \"--clean\" exclusive=#true\n flag \"--out <path>\" required=#true\n}\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run", "--clean"]))
.expect("a merged child exclusive flag is still the command's requiredness escape");
}
#[test]
fn a_group_allows_one_member_and_refuses_two() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file <f>\"\nflag \"--url <u>\"\nflag \"--stdin\"\ngroup \"input\" \"--file\" \"--url\" \"--stdin\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--file", "a.txt"])).expect("one member is fine");
parse(&spec, &input(&["ex"])).expect("a group that is not required asks for nothing");
let err = parse(&spec, &input(&["ex", "--file", "a.txt", "--stdin"])).unwrap_err();
assert!(err.to_string().contains("group input"), "{err}");
}
#[test]
fn positional_selectors_work_in_conflicts_and_groups() {
let conflicts: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--from-file <path>\" conflicts=\"value\"\narg \"[value]\"\n"
.parse()
.unwrap();
parse(&conflicts, &input(&["ex", "--from-file", "vars.env"]))
.expect("the flag alone is valid");
parse(&conflicts, &input(&["ex", "literal"])).expect("the positional alone is valid");
assert!(parse(
&conflicts,
&input(&["ex", "--from-file", "vars.env", "literal"])
)
.is_err());
let positional_source: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--from-file <path>\"\narg \"[value]\" conflicts=\"--from-file\"\n"
.parse()
.unwrap();
assert!(parse(
&positional_source,
&input(&["ex", "--from-file", "vars.env", "literal"])
)
.is_err());
let group: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file <path>\"\narg \"[target]\"\ngroup \"input\" \"--file\" \"target\" required=#true\n"
.parse()
.unwrap();
assert!(parse(&group, &input(&["ex"])).is_err());
parse(&group, &input(&["ex", "target-name"]))
.expect("a positional satisfies a required group");
assert!(parse(
&group,
&input(&["ex", "--file", "input.txt", "target-name"])
)
.is_err());
}
#[test]
fn a_required_group_needs_one_of_its_members() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
.parse()
.unwrap();
let err = parse(&spec, &input(&["ex"])).unwrap_err();
assert!(err.to_string().contains("--file, --url"), "{err}");
assert!(err.to_string().contains("input"), "{err}");
parse(&spec, &input(&["ex", "--url", "u"])).expect("one member satisfies it");
}
#[test]
fn a_multiple_group_only_polices_requiredness() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--a\"\nflag \"--b\"\ngroup \"any\" \"--a\" \"--b\" required=#true multiple=#true\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--a", "--b"])).expect("multiple allows both");
assert!(parse(&spec, &input(&["ex"])).is_err());
}
#[test]
fn a_group_reads_a_default_for_requiredness_and_not_for_exclusivity() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file <f>\" default=\"a.txt\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex"])).expect("the default fills the group");
parse(&spec, &input(&["ex", "--url", "u"]))
.expect("the default must not conflict with the flag the user typed");
}
#[test]
fn a_group_naming_two_spellings_of_one_flag_is_not_a_conflict() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-f --file <f>\"\nflag \"--url <u>\"\ngroup \"input\" \"-f\" \"--file\" \"--url\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--file", "a.txt"])).expect("one flag is one member");
parse(&spec, &input(&["ex", "-f", "a.txt"])).expect("either spelling, still one member");
let err = parse(&spec, &input(&["ex", "--file", "a.txt", "--url", "u"])).unwrap_err();
assert!(err.to_string().contains("group input"), "{err}");
}
#[test]
fn a_group_reads_the_environment_as_given() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--file <f>\" env=\"EX_FILE\"\nflag \"--url <u>\"\ngroup \"input\" \"--file\" \"--url\" required=#true\n"
.parse()
.unwrap();
parse_with_env(&spec, &["ex"], &[("EX_FILE", "a.txt")]).expect("the environment fills it");
}
#[test]
fn a_requirement_names_the_flag_that_is_missing() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out <p>\" requires=\"--format\"\nflag \"--format <f>\"\n"
.parse()
.unwrap();
let err = parse(&spec, &input(&["ex", "--out", "a.txt"])).unwrap_err();
assert!(
err.to_string().contains("format"),
"the missing flag should be named: {err}"
);
for words in [
&["ex", "--out", "a.txt", "--format", "json"][..],
&["ex", "--format", "json", "--out", "a.txt"][..],
] {
parse(&spec, &input(words)).unwrap_or_else(|e| panic!("{words:?}: {e}"));
}
parse(&spec, &input(&["ex"])).expect("a bare invocation requires nothing");
}
#[test]
fn a_value_activates_only_its_conditional_requirement() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--config <file>\" {\n requires_if \"special.toml\" \"--key\"\n requires_if \"remote.toml\" \"--token\"\n}\nflag \"--key <key>\"\nflag \"--token <token>\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--config", "ordinary.toml"]))
.expect("an unrelated value requires nothing");
let key = parse(&spec, &input(&["ex", "--config", "special.toml"])).unwrap_err();
assert!(key.to_string().contains("key"), "{key}");
parse(
&spec,
&input(&["ex", "--config", "special.toml", "--key", "secret"]),
)
.expect("the matching requirement is satisfied");
let token = parse(&spec, &input(&["ex", "--config", "remote.toml"])).unwrap_err();
assert!(token.to_string().contains("token"), "{token}");
}
#[test]
fn conditional_requirements_read_explicit_env_but_not_defaults() {
let from_env: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--config <file>\" env=\"EX_CONFIG\" {\n requires_if \"special.toml\" \"--key\"\n}\nflag \"--key <key>\"\n"
.parse()
.unwrap();
let err = parse_with_env(&from_env, &["ex"], &[("EX_CONFIG", "special.toml")]).unwrap_err();
assert!(err.to_string().contains("key"), "{err}");
let from_default: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--config <file>\" default=\"special.toml\" {\n requires_if \"special.toml\" \"--key\"\n}\nflag \"--key <key>\"\n"
.parse()
.unwrap();
parse(&from_default, &input(&["ex"]))
.expect("a default is not an explicit conditional value");
}
#[test]
fn command_line_values_override_env_for_conditional_requirements() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--config <file>\" env=\"EX_CONFIG\" {\n requires_if \"special.toml\" \"--key\"\n}\nflag \"--key <key>\"\n"
.parse()
.unwrap();
parse_with_env(
&spec,
&["ex", "--config", "ordinary.toml"],
&[("EX_CONFIG", "special.toml")],
)
.expect("the command-line value takes precedence over the environment");
}
#[test]
fn conditional_requirements_normalize_boolean_env_values() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--feature\" env=\"EX_FEATURE\" {\n requires_if \"true\" \"--key\"\n}\nflag \"--key <key>\"\n"
.parse()
.unwrap();
for value in ["1", "true", "True", "TRUE"] {
let err = parse_with_env(&spec, &["ex"], &[("EX_FEATURE", value)]).unwrap_err();
assert!(err.to_string().contains("key"), "{value}: {err}");
}
parse_with_env(&spec, &["ex"], &[("EX_FEATURE", "false")])
.expect("a false environment value does not activate a true condition");
}
#[test]
fn a_default_satisfies_a_requirement() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out <p>\" requires=\"--format\"\nflag \"--format <f>\" default=\"json\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--out", "a.txt"]))
.expect("a defaulted flag is not a missing one");
}
#[test]
fn a_present_flag_binds_a_conditional_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--bin-names\" {\n default_if \"--json\" \"true\"\n}\nflag \"--json\"\n"
.parse()
.unwrap();
let with = parse(&spec, &input(&["ex", "--json"])).unwrap();
assert_eq!(
with.as_env().get("usage_bin_names").map(String::as_str),
Some("true")
);
let without = parse(&spec, &input(&["ex"])).unwrap();
assert!(
!without.as_env().contains_key("usage_bin_names"),
"IsPresent does nothing when the selector is absent"
);
}
#[test]
fn an_equals_condition_binds_a_conditional_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--style <s>\" {\n default_if \"--output\" \"json\" \"pretty\"\n}\nflag \"--output <fmt>\"\n"
.parse()
.unwrap();
let json = parse(&spec, &input(&["ex", "--output", "json"])).unwrap();
assert_eq!(
json.as_env().get("usage_style").map(String::as_str),
Some("pretty")
);
let yaml = parse(&spec, &input(&["ex", "--output", "yaml"])).unwrap();
assert!(!yaml.as_env().contains_key("usage_style"));
}
#[test]
fn an_equals_condition_reads_a_negated_flag() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--pretty\" {\n default_if \"--json\" \"false\" \"true\"\n}\nflag \"--json\" negate=\"--no-json\"\n"
.parse()
.unwrap();
let off = parse(&spec, &input(&["ex", "--no-json"])).unwrap();
assert_eq!(
off.as_env().get("usage_pretty").map(String::as_str),
Some("true")
);
let on = parse(&spec, &input(&["ex", "--json"])).unwrap();
assert!(
!on.as_env().contains_key("usage_pretty"),
"--json is true, so when=false should miss"
);
}
#[test]
fn the_first_matching_conditional_default_wins() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--style <s>\" {\n default_if \"--json\" \"compact\"\n default_if \"--pretty\" \"pretty\"\n}\nflag \"--json\"\nflag \"--pretty\"\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "--json", "--pretty"])).unwrap();
assert_eq!(
out.as_env().get("usage_style").map(String::as_str),
Some("compact")
);
}
#[test]
fn argv_and_env_suppress_a_conditional_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--bin-names\" env=\"EX_BIN\" {\n default_if \"--json\" \"true\"\n}\nflag \"--json\"\n"
.parse()
.unwrap();
let from_env = parse_with_env(&spec, &["ex", "--json"], &[("EX_BIN", "false")]).unwrap();
assert_eq!(
from_env.as_env().get("usage_bin_names").map(String::as_str),
Some("false"),
"the target's environment wins over default_if"
);
}
#[test]
fn a_sibling_env_activates_a_conditional_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--bin-names\" {\n default_if \"--json\" \"true\"\n}\nflag \"--json\" env=\"EX_JSON\"\n"
.parse()
.unwrap();
let out = parse_with_env(&spec, &["ex"], &[("EX_JSON", "1")]).unwrap();
assert_eq!(
out.as_env().get("usage_bin_names").map(String::as_str),
Some("true")
);
}
#[test]
fn a_default_does_not_activate_a_conditional_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--pretty\" {\n default_if \"--json\" \"true\"\n}\nflag \"--json\" default=#true\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex"])).unwrap();
assert_eq!(
out.as_env().get("usage_json").map(String::as_str),
Some("true")
);
assert!(
!out.as_env().contains_key("usage_pretty"),
"a default is not an explicit value for default_if"
);
}
#[test]
fn a_conditional_default_does_not_activate_requires_if() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--format <f>\" {\n default_if \"--json\" \"json\"\n requires_if \"json\" \"--schema\"\n}\nflag \"--schema <s>\"\nflag \"--json\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--json"]))
.expect("a default_if value is not explicit for requires_if");
assert!(parse(&spec, &input(&["ex", "--format", "json"])).is_err());
}
#[test]
fn a_conditional_default_satisfies_a_requirement() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out <p>\" requires=\"--format\"\nflag \"--format <f>\" {\n default_if \"--json\" \"json\"\n}\nflag \"--json\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--out", "a.txt", "--json"]))
.expect("default_if fills the required flag");
assert!(parse(&spec, &input(&["ex", "--out", "a.txt"])).is_err());
}
#[test]
fn an_environment_value_satisfies_a_requirement() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--out <p>\" requires=\"--format\"\nflag \"--format <f>\" env=\"EX_FORMAT\"\n"
.parse()
.unwrap();
assert!(parse(&spec, &input(&["ex", "--out", "a.txt"])).is_err());
parse_with_env(&spec, &["ex", "--out", "a.txt"], &[("EX_FORMAT", "json")])
.expect("the environment supplies it");
}
#[test]
fn a_requirement_is_satisfied_by_a_short_form() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--sign\" requires=\"-k\"\nflag \"-k --key <k>\"\n"
.parse()
.unwrap();
parse(&spec, &input(&["ex", "--sign", "--key", "x"])).expect("--key satisfies -k");
let err = parse(&spec, &input(&["ex", "--sign"])).unwrap_err();
assert!(err.to_string().contains("key"), "{err}");
}
#[test]
fn conflicting_flags_are_rejected_in_either_order() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--file <f>\" conflicts=\"--stdin\"\nflag \"--stdin\"\n"
.parse()
.unwrap();
for words in [
&["ex", "--file", "a.txt", "--stdin"][..],
&["ex", "--stdin", "--file", "a.txt"][..],
] {
let err = parse(&spec, &input(words)).unwrap_err();
assert!(
err.to_string().contains("conflicts with --stdin"),
"{words:?} should be refused: {err}"
);
}
parse(&spec, &input(&["ex", "--stdin"])).unwrap();
parse(&spec, &input(&["ex", "--file", "a.txt"])).unwrap();
}
#[test]
fn unknown_flags_are_values_by_default() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--force\"\narg \"[rest]...\"\n"
.parse()
.unwrap();
let out = parse(
&spec,
&["ex".to_string(), "--wat".to_string(), "x".to_string()],
)
.unwrap();
let rest = out.args.keys().find(|a| a.name == "rest").unwrap();
assert_eq!(out.args[rest].to_string(), "--wat x");
}
#[test]
fn repeated_scalar_flags_override_by_default_and_can_be_strict() {
let permissive: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--jobs <n>\"\nflag \"--verbose\"\n"
.parse()
.unwrap();
let out = parse(&permissive, &input(&["ex", "--jobs", "1", "--jobs", "2"]))
.expect("a repeat is a correction by default");
let jobs = out.flags.keys().find(|f| f.name == "jobs").unwrap();
assert_eq!(out.flags[jobs].to_string(), "2");
parse(&permissive, &input(&["ex", "--verbose", "--verbose"]))
.expect("switches use the same default");
let strict: Spec = "name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--jobs <n>\"\nflag \"--verbose\"\n"
.parse()
.unwrap();
for words in [
&["ex", "--jobs", "1", "--jobs", "2"][..],
&["ex", "--verbose", "--verbose"][..],
] {
let err = parse(&strict, &input(words)).unwrap_err();
assert!(
err.to_string().contains("cannot be used multiple times"),
"{err}"
);
}
let reparsed: Spec = strict.to_string().parse().unwrap();
assert!(!reparsed.cmd.args_override_self);
}
#[test]
fn strict_negated_flags_allow_opposite_forms_but_reject_the_same_form() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--color\" negate=\"--no-color\"\n"
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "--color", "--no-color"]))
.expect("opposite forms override each other");
let color = out.flags.keys().find(|f| f.name == "color").unwrap();
assert!(matches!(out.flags[color], ParseValue::Bool(false)));
for words in [
&["ex", "--color", "--color"][..],
&["ex", "--no-color", "--no-color"][..],
] {
let err = parse(&spec, &input(words)).unwrap_err();
assert!(err.to_string().contains("cannot be used multiple times"));
}
}
#[test]
fn strict_global_flags_may_repeat_across_command_levels() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nargs_override_self #false\nflag \"--color\" negate=\"--no-color\" global=#true\nflag \"--jobs <n>\" global=#true\ncmd \"run\" {\n args_override_self #false\n}\n"
.parse()
.unwrap();
let out = parse(
&spec,
&input(&[
"ex", "--color", "--jobs", "1", "run", "--color", "--jobs", "2",
]),
)
.expect("an inherited global is allowed once at each command level");
let jobs = out.flags.keys().find(|f| f.name == "jobs").unwrap();
assert_eq!(out.flags[jobs].to_string(), "2");
for words in [
&["ex", "--color", "--color", "run", "--no-color"][..],
&["ex", "--jobs", "1", "run", "--jobs", "2", "--jobs", "3"][..],
] {
let err = parse(&spec, &input(words)).unwrap_err();
assert!(err.to_string().contains("cannot be used multiple times"));
}
}
#[test]
fn a_subcommand_can_negate_only_its_parents_requirements() {
let base = r#"name "ex"
bin "ex"
flag "--config" required=#true
flag "--mode" requires="--config"
flag "--other"
arg "<input>"
group "source" "--config" "--other" required=#true
cmd "run" { flag "--child" required=#true }
"#;
let strict: Spec = base.parse().unwrap();
let err = parse(&strict, &input(&["ex", "run"])).unwrap_err();
let message = err.to_string();
assert!(
message.contains("input") || message.contains("config"),
"{message}"
);
let negated: Spec = base
.replacen("bin \"ex\"", "bin \"ex\"\nsubcommand_negates_reqs #true", 1)
.parse()
.unwrap();
let err = parse(&negated, &input(&["ex", "run"])).unwrap_err();
assert!(
err.to_string().contains("child"),
"the selected command keeps its own requirements: {err}"
);
let mut child_optional = negated.clone();
child_optional.cmd.subcommands["run"].flags[0].required = false;
parse(&child_optional, &input(&["ex", "run"]))
.expect("the child selection satisfies all parent requirements");
parse(&child_optional, &input(&["ex", "--mode", "run"]))
.expect("parent requires relationships are negated too");
}
#[test]
fn a_parent_argument_can_conflict_with_a_later_subcommand() {
let spec: Spec = r#"name "ex"
bin "ex"
args_conflicts_with_subcommands #true
flag "--verbose"
cmd "run"
"#
.parse()
.unwrap();
parse(&spec, &input(&["ex", "run"]))
.expect("the subcommand is valid without a parent argument");
let err = parse(&spec, &input(&["ex", "--verbose", "run"])).unwrap_err();
assert!(
err.to_string().contains("cannot be used with arguments"),
"{err}"
);
}
#[test]
fn a_subcommand_can_take_precedence_over_a_variadic_flag() {
let base = r#"name "ex"
bin "ex"
flag "--values <value>..."
cmd "run"
"#;
let plain: Spec = base.parse().unwrap();
let out = parse(&plain, &input(&["ex", "--values", "a", "run"])).unwrap();
assert_eq!(out.cmd.name, "ex");
let precedence: Spec = base
.replacen(
"bin \"ex\"",
"bin \"ex\"\nsubcommand_precedence_over_arg #true",
1,
)
.parse()
.unwrap();
let out = parse(&precedence, &input(&["ex", "--values", "a", "run"])).unwrap();
assert_eq!(out.cmd.name, "run");
}
#[test]
fn a_required_positional_can_follow_an_unfilled_optional_one() {
let base = r#"name "ex"
bin "ex"
arg "[optional]"
arg "<required>"
"#;
let plain: Spec = base.parse().unwrap();
let err = parse(&plain, &input(&["ex", "value"])).unwrap_err();
assert!(err.to_string().contains("required"), "{err}");
let enabled: Spec = base
.replacen(
"bin \"ex\"",
"bin \"ex\"\nallow_missing_positional #true",
1,
)
.parse()
.unwrap();
let out = parse(&enabled, &input(&["ex", "value"])).unwrap();
assert!(!out.args.keys().any(|arg| arg.name == "optional"));
let value = &out
.args
.iter()
.find(|(arg, _)| arg.name == "required")
.unwrap()
.1;
assert!(matches!(value, ParseValue::String(value) if value == "value"));
}
#[test]
fn unknown_flags_can_be_rejected_for_the_whole_cli() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nunknown_flags \"error\"\nflag \"--force\"\nflag \"-0 --print0\"\narg \"[rest]...\" allow_negative_numbers=#true\n"
.parse()
.unwrap();
let err = parse(&spec, &["ex".to_string(), "--wat".to_string()]).unwrap_err();
assert!(
err.to_string().contains("--wat"),
"the message should name the token: {err}"
);
let out = parse(&spec, &["ex".to_string(), "-1".to_string()]).unwrap();
let rest = out.args.keys().find(|a| a.name == "rest").unwrap();
assert_eq!(out.args[rest].to_string(), "-1");
let out = parse(&spec, &["ex".to_string(), "-0".to_string()]).unwrap();
let print0 = out.flags.keys().find(|flag| flag.name == "print0").unwrap();
assert!(matches!(out.flags[print0], ParseValue::Bool(true)));
}
#[test]
fn a_declared_digit_short_does_not_stop_the_subcommand_scan() {
let spec: Spec = r#"
name "ex"
bin "ex"
unknown_flags "error"
flag "-0 --print0" global=#true
cmd "run" {
flag "--force"
}
"#
.parse()
.unwrap();
let out = parse(&spec, &input(&["ex", "-0", "run", "--force"])).unwrap();
assert_eq!(out.cmd.name, "run");
let print0 = out.flags.keys().find(|flag| flag.name == "print0").unwrap();
let force = out.flags.keys().find(|flag| flag.name == "force").unwrap();
assert!(matches!(out.flags[print0], ParseValue::Bool(true)));
assert!(matches!(out.flags[force], ParseValue::Bool(true)));
}
#[test]
fn a_command_may_override_the_cli_wide_setting() {
let spec: Spec = r#"
name "ex"
bin "ex"
unknown_flags "error"
cmd "exec" unknown_flags="value" {
arg "[rest]..."
}
cmd "build" {
arg "[rest]..."
}
"#
.parse()
.unwrap();
let out = parse(
&spec,
&["ex".to_string(), "exec".to_string(), "--wat".to_string()],
)
.unwrap();
let rest = out.args.keys().find(|a| a.name == "rest").unwrap();
assert_eq!(out.args[rest].to_string(), "--wat");
assert!(
parse(
&spec,
&["ex".to_string(), "build".to_string(), "--wat".to_string()]
)
.is_err(),
"a command that says nothing inherits the CLI's choice"
);
}
#[test]
fn the_setting_survives_a_round_trip() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nunknown_flags \"error\"\ncmd \"x\" unknown_flags=\"value\"\n"
.parse()
.unwrap();
let reparsed: Spec = spec.to_string().parse().unwrap();
assert_eq!(reparsed.unknown_flags, Some(UnknownFlags::Error));
assert_eq!(
reparsed.cmd.subcommands["x"].unknown_flags,
Some(UnknownFlags::Value)
);
}
#[test]
fn test_default_subcommand() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
default_subcommand: Some("run".to_string()),
..Default::default()
};
let input = vec!["test".to_string(), "mytask".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 2);
assert_eq!(parsed.cmds[1].name, "run");
assert_eq!(parsed.args.len(), 1);
let arg = parsed.args.keys().next().unwrap();
assert_eq!(arg.name, "task");
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "mytask");
}
#[test]
fn test_default_subcommand_explicit_still_works() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.build();
let other_cmd = SpecCommand::builder()
.name("other")
.arg(SpecArg::builder().name("other_arg").build())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
cmd.subcommands.insert("other".to_string(), other_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
default_subcommand: Some("run".to_string()),
..Default::default()
};
let input = vec!["test".to_string(), "other".to_string(), "foo".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 2);
assert_eq!(parsed.cmds[1].name, "other");
}
#[test]
fn test_default_subcommand_applies_only_at_the_root() {
let mut config_ls = SpecCommand::builder().name("ls").build();
config_ls.args.push(SpecArg::builder().name("what").build());
let mut config_cmd = SpecCommand::builder().name("config").build();
config_cmd.subcommands.insert("ls".to_string(), config_ls);
let mut root_ls = SpecCommand::builder().name("ls").build();
root_ls.args.push(SpecArg::builder().name("what").build());
let mut cmd = SpecCommand::builder().name("ex").build();
cmd.subcommands.insert("ls".to_string(), root_ls);
cmd.subcommands.insert("config".to_string(), config_cmd);
let spec = Spec {
name: "ex".to_string(),
bin: "ex".to_string(),
cmd,
default_subcommand: Some("ls".to_string()),
..Default::default()
};
let input = vec!["ex".to_string(), "config".to_string(), "zzz".to_string()];
assert!(
parse(&spec, &input).is_err(),
"`config` has no default subcommand and no argument, so `zzz` cannot bind"
);
let input = vec!["ex".to_string(), "zzz".to_string()];
let parsed = parse(&spec, &input).expect("the root's default applies");
assert_eq!(
parsed
.cmds
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
["ex", "ls"]
);
assert_eq!(
parsed.args.values().next().map(|v| v.to_string()),
Some("zzz".to_string()),
"and the word binds inside the command it reached"
);
}
#[test]
fn test_default_subcommand_with_nested_subcommands() {
let say_cmd = SpecCommand::builder()
.name("say")
.arg(SpecArg::builder().name("name").build())
.build();
let mut run_cmd = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert("say".to_string(), say_cmd);
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
default_subcommand: Some("run".to_string()),
..Default::default()
};
let input = vec!["test".to_string(), "say".to_string(), "hello".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 3);
assert_eq!(parsed.cmds[0].name, "test");
assert_eq!(parsed.cmds[1].name, "run");
assert_eq!(parsed.cmds[2].name, "say");
assert_eq!(parsed.args.len(), 1);
let arg = parsed.args.keys().next().unwrap();
assert_eq!(arg.name, "name");
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "hello");
}
fn mounted_global_flag_spec() -> Spec {
let task_cmd = SpecCommand::builder()
.name("sample:run")
.arg(
SpecArg::builder()
.name("profile")
.choices(["alpha", "beta", "gamma"])
.build(),
)
.build();
let mut run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("cd")
.short('C')
.long("cd")
.arg(SpecArg::builder().name("dir").build())
.global(false)
.build(),
)
.build();
run_cmd
.subcommands
.insert("sample:run".to_string(), task_cmd);
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("cd")
.short('C')
.long("cd")
.arg(SpecArg::builder().name("dir").build())
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
#[test]
fn test_prefix_global_flag_does_not_pollute_choices() {
let spec = mounted_global_flag_spec();
for words in [
&["test", "-C", "/tmp", "run", "sample:run"][..],
&["test", "--cd=/tmp", "run", "sample:run"][..],
] {
let parsed = parse_partial(&spec, &input(words)).unwrap();
assert_eq!(
parsed
.cmds
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["test", "run", "sample:run"],
);
assert!(
parsed.args.is_empty(),
"args should be empty, got {:?}",
parsed.args
);
let cd = parsed
.available_flags
.get("--cd")
.expect("--cd should remain available after descending into the subcommand");
assert!(cd.global, "--cd must stay global after descent");
assert!(
parsed.available_flags.get("-C").is_some_and(|f| f.global),
"-C must stay global after descent",
);
assert_eq!(
parsed.as_env().get("usage_cd").map(String::as_str),
Some("/tmp"),
"global flag value must survive in as_env(), got {:?}",
parsed.as_env(),
);
}
let parsed = parse_partial(
&spec,
&input(&["test", "-C", "/tmp", "run", "sample:run", "alpha"]),
)
.unwrap();
assert_eq!(parsed.args.len(), 1);
assert_eq!(parsed.args.values().next().unwrap().to_string(), "alpha");
assert_parse_err(
parse_partial(&spec, &input(&["test", "run", "sample:run", "wrong"])),
"Invalid choice for arg profile: wrong, expected one of alpha, beta, gamma",
);
}
fn mounted_orphan_short_spec() -> Spec {
let task_cmd = SpecCommand::builder()
.name("sample:run")
.arg(
SpecArg::builder()
.name("profile")
.choices(["alpha", "beta", "gamma"])
.build(),
)
.build();
let mut run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("raw")
.short('r')
.long("raw")
.global(false)
.build(),
)
.flag(
SpecFlag::builder()
.name("force")
.short('f')
.long("force")
.global(false)
.build(),
)
.build();
run_cmd
.subcommands
.insert("sample:run".to_string(), task_cmd);
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("raw")
.long("raw")
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
#[test]
fn test_orphan_short_alias_survives_merge() {
let spec = mounted_orphan_short_spec();
let parsed = parse_partial(&spec, &input(&["test", "run", "-r", "sample:run"])).unwrap();
assert_eq!(
parsed
.cmds
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["test", "run", "sample:run"],
);
assert!(
parsed.available_flags.get("-r").is_some_and(|f| f.global),
"-r must be merged onto the inherited global flag and stay global after descent",
);
assert!(
parsed
.available_flags
.get("--raw")
.is_some_and(|f| f.global),
"--raw must stay global after descent",
);
assert!(
parsed.args.is_empty(),
"args should be empty, got {:?}",
parsed.args
);
assert_eq!(
parsed.as_env().get("usage_raw").map(String::as_str),
Some("true"),
"merged short's value must survive in as_env(), got {:?}",
parsed.as_env(),
);
assert!(
!parsed.available_flags.contains_key("-f"),
"purely-local -f must not be promoted onto a global",
);
assert!(
!parsed.available_flags.contains_key("--force"),
"purely-local --force must not be promoted onto a global",
);
let parsed =
parse_partial(&spec, &input(&["test", "run", "-r", "sample:run", "alpha"])).unwrap();
assert_eq!(parsed.args.len(), 1);
assert_eq!(parsed.args.values().next().unwrap().to_string(), "alpha");
assert_parse_err(
parse_partial(&spec, &input(&["test", "run", "-r", "sample:run", "wrong"])),
"Invalid choice for arg profile: wrong, expected one of alpha, beta, gamma",
);
}
#[test]
fn test_orphan_short_does_not_clobber_unrelated_global() {
let run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("raw")
.short('r')
.long("raw")
.global(false)
.build(),
)
.build();
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("raw")
.long("raw")
.global(true)
.build(),
)
.flag(
SpecFlag::builder()
.name("restrict")
.short('r')
.long("restrict")
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
assert_eq!(
parsed.available_flags.get("-r").map(|f| f.name.as_str()),
Some("restrict"),
"-r must remain owned by the unrelated global it already belonged to",
);
assert!(parsed
.available_flags
.get("--raw")
.is_some_and(|f| f.global));
assert!(parsed
.available_flags
.get("--restrict")
.is_some_and(|f| f.global));
}
#[test]
fn test_redeclared_global_aliases_share_one_flag() {
let spec = r#"
flag "-y --yes" global=#true effect="write"
cmd "run" {
flag "-y --yes --assume-yes"
}
"#
.parse::<Spec>()
.unwrap();
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
for key in ["-y", "--yes", "--assume-yes"] {
let flag = parsed
.available_flags
.get(key)
.unwrap_or_else(|| panic!("{key} must be recognized after the descent"));
assert!(flag.global, "{key} must stay global after the descent");
assert_eq!(
flag.long,
vec!["yes".to_string(), "assume-yes".to_string()],
"{key} must resolve to the flag carrying every alias",
);
assert_eq!(flag.short, vec!['y'], "{key} must keep the global's short");
}
assert_eq!(
unique_flags(parsed.available_flags.values()).count(),
1,
"all aliases must point at one flag object, got {:?}",
parsed.available_flags,
);
assert_eq!(
parsed.available_flags["-y"].effect,
Some(crate::SpecCommandEffect::Write),
);
}
#[test]
fn test_redeclared_global_keeps_hidden_alias_metadata() {
let spec = r#"
flag "--yes" global=#true {
alias "-q" "--quietly" hide=#true
}
cmd "run" {
flag "--yes --assume-yes" {
alias "-s" "--secret" hide=#true
}
}
"#
.parse::<Spec>()
.unwrap();
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
let merged = &parsed.available_flags["--yes"];
assert_eq!(merged.hidden_short_aliases, ['q', 's']);
assert_eq!(merged.hidden_aliases, ["quietly", "secret"]);
for key in ["-q", "-s", "--quietly", "--secret"] {
assert!(Arc::ptr_eq(&parsed.available_flags[key], merged), "{key}");
}
}
#[test]
fn test_redeclared_global_can_promote_hidden_aliases() {
let spec = r#"
flag "--yes" global=#true {
alias "-q" "--quietly" hide=#true
}
cmd "run" {
flag "-q --yes --quietly"
}
"#
.parse::<Spec>()
.unwrap();
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
let merged = &parsed.available_flags["--yes"];
assert!(merged.hidden_short_aliases.is_empty());
assert!(merged.hidden_aliases.is_empty());
for key in ["-q", "--quietly"] {
assert!(Arc::ptr_eq(&parsed.available_flags[key], merged), "{key}");
}
}
#[test]
fn test_partially_redeclared_global_keeps_all_aliases_on_one_flag() {
let spec = r#"
flag "-y --yes --confirm" global=#true
cmd "run" {
flag "--yes --assume-yes"
}
"#
.parse::<Spec>()
.unwrap();
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
for key in ["-y", "--yes", "--confirm", "--assume-yes"] {
let flag = parsed
.available_flags
.get(key)
.unwrap_or_else(|| panic!("{key} must be recognized after the descent"));
assert!(flag.global, "{key} must stay global after the descent");
assert_eq!(
flag.long,
vec![
"yes".to_string(),
"confirm".to_string(),
"assume-yes".to_string()
],
"{key} must resolve to the flag carrying every alias",
);
}
assert_eq!(
unique_flags(parsed.available_flags.values()).count(),
1,
"all aliases must point at one flag object, got {:?}",
parsed.available_flags,
);
}
fn mounted_task_flag_spec() -> Spec {
let mut task_cmd = SpecCommand::builder()
.name("mytask")
.flag(
SpecFlag::builder()
.name("env")
.long("env")
.arg(
SpecArg::builder()
.name("name")
.choices(["dev", "stage", "prod"])
.build(),
)
.global(false)
.build(),
)
.flag(
SpecFlag::builder()
.name("bump")
.long("bump")
.arg(
SpecArg::builder()
.name("type")
.choices(["auto", "major"])
.build(),
)
.global(false)
.build(),
)
.build();
task_cmd.mounted = true;
let mut run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("force")
.short('f')
.long("force")
.global(false)
.build(),
)
.build();
run_cmd.subcommands.insert("mytask".to_string(), task_cmd);
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("env")
.short('E')
.long("env")
.arg(SpecArg::builder().name("ENV").build())
.global(true)
.build(),
)
.flag(
SpecFlag::builder()
.name("silent")
.long("silent")
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
#[test]
fn test_mount_boundary_does_not_apply_inside_the_mounted_tree() {
let deep = SpecCommand::builder().name("deep").build();
let mut sub = SpecCommand::builder()
.name("sub")
.flag(
SpecFlag::builder()
.name("cd")
.short('C')
.long("cd")
.arg(SpecArg::builder().name("dir").build())
.global(false)
.build(),
)
.build();
sub.subcommands.insert("deep".to_string(), deep);
let mut task = SpecCommand::builder()
.name("task")
.flag(
SpecFlag::builder()
.name("cd")
.short('C')
.long("cd")
.arg(SpecArg::builder().name("dir").build())
.global(true)
.build(),
)
.build();
task.subcommands.insert("sub".to_string(), sub);
task.mark_mounted();
let mut run_cmd = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert("task".to_string(), task);
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed = parse_partial(&spec, &input(&["test", "run", "task", "sub", "deep"])).unwrap();
assert!(
parsed.available_flags.get("--cd").is_some_and(|f| f.global),
"the mounted program's own global must survive descents inside the mounted tree",
);
assert!(
parsed.completion_flags().contains_key("--cd"),
"and must still be offered there: it belongs to the mounted program",
);
assert!(
parsed.completion_flags().contains_key("-C"),
"including the short the nested command re-declared",
);
}
#[test]
fn test_mount_flags_merged_into_the_mounting_cmd_are_offered() {
let mut task = SpecCommand::builder()
.name("task")
.flag(
SpecFlag::builder()
.name("bump")
.long("bump")
.global(false)
.build(),
)
.build();
task.mark_mounted();
let mut run_cmd = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert("task".to_string(), task);
run_cmd.flags = vec![
SpecFlag::builder()
.name("tglobal")
.long("tglobal")
.global(true)
.build(),
SpecFlag::builder()
.name("tlocal")
.long("tlocal")
.global(false)
.build(),
];
run_cmd.flags_from_mount = true;
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("silent")
.long("silent")
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed = parse_partial(&spec, &input(&["test", "run", "task"])).unwrap();
assert_eq!(
parsed.completion_flags().keys().collect::<Vec<_>>(),
vec!["--bump", "--tglobal"],
"the mounted spec's root global belongs to the mounted program; the mounting CLI's \
`--silent` does not, and the mount's non-global root flag is not inherited",
);
}
#[test]
fn test_mounted_cmd_does_not_offer_mounting_cli_globals() {
let spec = mounted_task_flag_spec();
let parsed = parse_partial(&spec, &input(&["test", "run", "mytask"])).unwrap();
assert!(parsed.available_flags.contains_key("--silent"));
assert!(parsed.available_flags.contains_key("-E"));
assert_eq!(
parsed.completion_flags().keys().collect::<Vec<_>>(),
vec!["--bump", "--env"],
"only the mounted command's own flags may be offered",
);
assert!(!parsed.available_flags.contains_key("--force"));
}
#[test]
fn test_mounted_cmd_flag_wins_over_inherited_global() {
let spec = mounted_task_flag_spec();
let parsed = parse_partial(&spec, &input(&["test", "run", "mytask", "--env"])).unwrap();
let awaiting = parsed
.flag_awaiting_value
.first()
.expect("--env should await a value");
assert_eq!(
awaiting
.arg
.as_ref()
.and_then(|a| a.choices.as_ref())
.map(|c| c.choices.clone()),
Some(vec![
"dev".to_string(),
"stage".to_string(),
"prod".to_string()
]),
"the mounted command's own --env must win over the inherited global",
);
let parsed =
parse_partial(&spec, &input(&["test", "-E", "anything", "run", "mytask"])).unwrap();
assert!(
parsed.args.is_empty(),
"prefix global tokens must not be consumed as positionals, got {:?}",
parsed.args
);
assert_eq!(
parsed.as_env().get("usage_env").map(String::as_str),
Some("anything"),
);
}
#[test]
fn test_prefix_flag_keeps_the_flag_it_was_read_as() {
let spec = mounted_task_flag_spec();
let parsed = parse_partial(
&spec,
&input(&["test", "--env", "not-a-task-choice", "run", "mytask"]),
)
.unwrap();
assert!(
parsed.errors.is_empty(),
"prefix global value must not be validated against the mounted flag: {:?}",
parsed
.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>(),
);
assert_eq!(
parsed.as_env().get("usage_env").map(String::as_str),
Some("not-a-task-choice"),
);
let parsed = parse_partial(
&spec,
&input(&["test", "--env=not-a-task-choice", "run", "mytask"]),
)
.unwrap();
assert!(parsed.errors.is_empty());
assert_eq!(
parsed.as_env().get("usage_env").map(String::as_str),
Some("not-a-task-choice"),
);
let parsed = parse_partial(
&spec,
&input(&["test", "--env", "prod", "run", "mytask", "--env"]),
)
.unwrap();
let awaiting = parsed
.flag_awaiting_value
.first()
.expect("--env should await a value");
assert_eq!(
awaiting
.arg
.as_ref()
.and_then(|a| a.choices.as_ref())
.map(|c| c.choices.clone()),
Some(vec![
"dev".to_string(),
"stage".to_string(),
"prod".to_string()
]),
"the mounted command's --env must own the name after the mounted command",
);
}
#[test]
fn test_non_global_flag_does_not_hide_subcommand() {
let spec = mounted_task_flag_spec();
for words in [
&["test", "run", "--force", "mytask"][..],
&["test", "run", "-f", "mytask"][..],
&["test", "-E", "prod", "run", "--force", "mytask"][..],
] {
let parsed = parse_partial(&spec, &input(words)).unwrap();
assert_eq!(
parsed
.cmds
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["test", "run", "mytask"],
"{words:?} should descend into the mounted command",
);
assert!(
parsed.args.is_empty(),
"{words:?} should not consume a positional, got {:?}",
parsed.args,
);
assert_eq!(
parsed.as_env().get("usage_force").map(String::as_str),
Some("true"),
"the non-global flag must still be recorded for {words:?}",
);
}
let mut run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("output")
.short('o')
.long("output")
.arg(SpecArg::builder().name("mode").build())
.global(false)
.build(),
)
.build();
run_cmd.subcommands.insert(
"task".to_string(),
SpecCommand::builder().name("task").build(),
);
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed =
parse_partial(&spec, &input(&["test", "run", "--output", "quiet", "task"])).unwrap();
assert_eq!(
parsed
.cmds
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["test", "run", "task"],
);
assert_eq!(
parsed.as_env().get("usage_output").map(String::as_str),
Some("quiet"),
);
assert_parse_err(
parse_partial(&spec, &input(&["test", "run", "--nope", "task"])),
"unexpected word: --nope",
);
}
#[test]
fn test_non_mounted_subcommand_offers_inherited_globals() {
let mut run_cmd = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert(
"nested".to_string(),
SpecCommand::builder().name("nested").build(),
);
let mut cmd = SpecCommand::builder()
.name("test")
.flag(
SpecFlag::builder()
.name("silent")
.long("silent")
.global(true)
.build(),
)
.build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed = parse_partial(&spec, &input(&["test", "run", "nested"])).unwrap();
assert_eq!(
parsed.completion_flags().keys().collect::<Vec<_>>(),
parsed.available_flags.keys().collect::<Vec<_>>(),
);
assert!(parsed.completion_flags().contains_key("--silent"));
}
#[test]
fn test_subcommand_alias_collision_keeps_last_owner() {
let run_cmd = SpecCommand::builder()
.name("run")
.flag(
SpecFlag::builder()
.name("alpha")
.short('x')
.long("alpha")
.global(false)
.build(),
)
.flag(
SpecFlag::builder()
.name("beta")
.short('x')
.long("beta")
.global(false)
.build(),
)
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let parsed = parse_partial(&spec, &input(&["test", "run"])).unwrap();
assert_eq!(
parsed.available_flags.get("-x").map(|f| f.name.as_str()),
Some("beta"),
"the last-declared flag must keep a shared short alias",
);
assert_eq!(
parsed
.available_flags
.get("--alpha")
.map(|f| f.name.as_str()),
Some("alpha"),
);
assert_eq!(
parsed
.available_flags
.get("--beta")
.map(|f| f.name.as_str()),
Some("beta"),
);
}
#[test]
fn test_default_subcommand_same_name_child() {
let run_task = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("args").build())
.build();
let mut run_cmd = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert("run".to_string(), run_task);
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
default_subcommand: Some("run".to_string()),
..Default::default()
};
let input = vec!["test".to_string(), "run".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 2);
assert_eq!(parsed.cmds[0].name, "test");
assert_eq!(parsed.cmds[1].name, "run");
let input = vec![
"test".to_string(),
"run".to_string(),
"run".to_string(),
"hello".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 3);
assert_eq!(parsed.cmds[0].name, "test");
assert_eq!(parsed.cmds[1].name, "run");
assert_eq!(parsed.cmds[2].name, "run");
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "hello");
let mut run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.build();
let run_task = SpecCommand::builder().name("run").build();
run_cmd.subcommands.insert("run".to_string(), run_task);
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
default_subcommand: Some("run".to_string()),
..Default::default()
};
let input = vec!["test".to_string(), "other".to_string()];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.cmds.len(), 2);
assert_eq!(parsed.cmds[0].name, "test");
assert_eq!(parsed.cmds[1].name, "run");
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "other");
}
#[test]
fn test_restart_token() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.restart_token(":::".to_string())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"task1".to_string(),
":::".to_string(),
"task2".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "task2");
}
#[test]
fn test_restart_token_multiple() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.restart_token(":::".to_string())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"task1".to_string(),
":::".to_string(),
"task2".to_string(),
":::".to_string(),
"task3".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "task3");
}
#[test]
fn test_restart_token_clears_flag_awaiting_value() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.flag(
SpecFlag::builder()
.name("jobs")
.long("jobs")
.arg(SpecArg::builder().name("count").build())
.build(),
)
.restart_token(":::".to_string())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"task1".to_string(),
"--jobs".to_string(),
":::".to_string(),
"task2".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 1);
let value = parsed.args.values().next().unwrap();
assert_eq!(value.to_string(), "task2");
assert!(parsed.flag_awaiting_value.is_empty());
}
#[test]
fn test_restart_token_resets_double_dash() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.arg(SpecArg::builder().name("extra_args").var(true).build())
.flag(SpecFlag::builder().name("verbose").long("verbose").build())
.restart_token(":::".to_string())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"task1".to_string(),
"--".to_string(),
"extra".to_string(),
":::".to_string(),
"--verbose".to_string(),
"task2".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
assert!(parsed.flags.keys().any(|f| f.name == "verbose"));
let task_arg = parsed.args.keys().find(|a| a.name == "task").unwrap();
let value = parsed.args.get(task_arg).unwrap();
assert_eq!(value.to_string(), "task2");
}
#[test]
fn test_double_dashes_without_preserve() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("args").var(true).build())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"arg1".to_string(),
"--".to_string(),
"arg2".to_string(),
"--".to_string(),
"arg3".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
let args_arg = parsed.args.keys().find(|a| a.name == "args").unwrap();
let value = parsed.args.get(args_arg).unwrap();
assert_eq!(value.to_string(), "arg1 arg2 -- arg3");
}
#[test]
fn test_double_dashes_with_preserve() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(
SpecArg::builder()
.name("args")
.var(true)
.double_dash(SpecDoubleDashChoices::Preserve)
.build(),
)
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"arg1".to_string(),
"--".to_string(),
"arg2".to_string(),
"--".to_string(),
"arg3".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
let args_arg = parsed.args.keys().find(|a| a.name == "args").unwrap();
let value = parsed.args.get(args_arg).unwrap();
assert_eq!(value.to_string(), "arg1 -- arg2 -- arg3");
}
#[test]
fn test_double_dashes_with_preserve_only_dashes() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(
SpecArg::builder()
.name("args")
.var(true)
.double_dash(SpecDoubleDashChoices::Preserve)
.build(),
)
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"--".to_string(),
"--".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
let args_arg = parsed.args.keys().find(|a| a.name == "args").unwrap();
let value = parsed.args.get(args_arg).unwrap();
assert_eq!(value.to_string(), "-- --");
}
#[test]
fn test_double_dashes_with_preserve_multiple_args() {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.arg(
SpecArg::builder()
.name("extra_args")
.var(true)
.double_dash(SpecDoubleDashChoices::Preserve)
.build(),
)
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input = vec![
"test".to_string(),
"run".to_string(),
"task1".to_string(),
"--".to_string(),
"arg1".to_string(),
"--".to_string(),
"--foo".to_string(),
];
let parsed = parse(&spec, &input).unwrap();
let task_arg = parsed.args.keys().find(|a| a.name == "task").unwrap();
let task_value = parsed.args.get(task_arg).unwrap();
assert_eq!(task_value.to_string(), "task1");
let extra_arg = parsed.args.keys().find(|a| a.name == "extra_args").unwrap();
let extra_value = parsed.args.get(extra_arg).unwrap();
assert_eq!(extra_value.to_string(), "-- arg1 -- --foo");
}
fn spec_with_args(args: impl IntoIterator<Item = SpecArg>) -> Spec {
let cmd = SpecCommand::builder().name("test").args(args).build();
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
fn arg_value(parsed: &ParseOutput, name: &str) -> String {
let arg = parsed
.args
.keys()
.find(|a| a.name == name)
.unwrap_or_else(|| panic!("expected arg {name} to be parsed"));
parsed.args.get(arg).unwrap().to_string()
}
fn required_arg(name: &str) -> SpecArg {
SpecArg::builder()
.name(name)
.var(true)
.required(false)
.double_dash(SpecDoubleDashChoices::Required)
.build()
}
#[test]
fn test_double_dash_required_reports_error_once_for_variadic() {
let spec = spec_with_args([required_arg("files")]);
let parsed = parse_partial(&spec, &input(&["test", "a", "b", "c"])).unwrap();
assert!(parsed.args.is_empty());
assert_eq!(parsed.errors.len(), 1);
assert!(
matches!(&parsed.errors[0], UsageErr::ArgRequiresDoubleDash(name) if name == "files")
);
}
#[test]
fn test_double_dash_required_suppresses_missing_arg() {
let spec = spec_with_args([SpecArg::builder()
.name("file")
.required(true)
.double_dash(SpecDoubleDashChoices::Required)
.build()]);
let parsed = parse_partial(&spec, &input(&["test", "x"])).unwrap();
assert_eq!(parsed.errors.len(), 1);
assert!(matches!(
&parsed.errors[0],
UsageErr::ArgRequiresDoubleDash(_)
));
assert_eq!(
parsed.next_arg.as_ref().map(|a| a.name.as_str()),
Some("file")
);
assert!(!parsed.double_dash_seen);
}
#[test]
fn test_double_dash_routes_to_required_arg() {
let spec = spec_with_args([
SpecArg::builder()
.name("tool")
.var(true)
.required(false)
.build(),
required_arg("command"),
]);
let parsed = parse(&spec, &input(&["test", "node@20", "--", "node", "app.js"])).unwrap();
assert_eq!(arg_value(&parsed, "tool"), "node@20");
assert_eq!(arg_value(&parsed, "command"), "node app.js");
assert!(parsed.double_dash_seen);
}
#[test]
fn test_double_dash_routes_with_gap_reports_missing_arg() {
let spec = spec_with_args([
SpecArg::builder()
.name("tool")
.var(true)
.required(true)
.build(),
required_arg("command"),
]);
let parsed = parse_partial(&spec, &input(&["test", "--", "ls"])).unwrap();
assert_eq!(arg_value(&parsed, "command"), "ls");
assert!(parsed.args.keys().all(|a| a.name != "tool"));
assert!(parsed
.errors
.iter()
.any(|e| matches!(e, UsageErr::MissingArg(name) if name == "tool")));
}
#[test]
fn test_double_dash_gap_applies_defaults() {
let spec = spec_with_args([
SpecArg::builder()
.name("tool")
.var(true)
.required(false)
.default_value("node@20")
.build(),
required_arg("command"),
]);
let parsed = parse(&spec, &input(&["test", "--", "ls"])).unwrap();
assert_eq!(arg_value(&parsed, "command"), "ls");
assert_eq!(arg_value(&parsed, "tool"), "node@20");
}
fn spec_with_restart_token_and_required_arg() -> Spec {
let run_cmd = SpecCommand::builder()
.name("run")
.arg(SpecArg::builder().name("task").build())
.arg(required_arg("run_args"))
.restart_token(":::".to_string())
.build();
let mut cmd = SpecCommand::builder().name("test").build();
cmd.subcommands.insert("run".to_string(), run_cmd);
Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
}
}
#[test]
fn test_double_dash_required_restart_token_resets_separator() {
let spec = spec_with_restart_token_and_required_arg();
let parsed = parse_partial(
&spec,
&input(&["test", "run", "task1", "--", "a", ":::", "task2", "b"]),
)
.unwrap();
assert_eq!(arg_value(&parsed, "task"), "task2");
assert!(parsed.args.keys().all(|a| a.name != "run_args"));
assert_eq!(
parsed
.errors
.iter()
.filter(|e| matches!(e, UsageErr::ArgRequiresDoubleDash(_)))
.count(),
1
);
}
#[test]
fn test_double_dash_required_restart_token_accepts_new_separator() {
let spec = spec_with_restart_token_and_required_arg();
let parsed = parse(
&spec,
&input(&["test", "run", "task1", "--", "a", ":::", "task2", "--", "c"]),
)
.unwrap();
assert_eq!(arg_value(&parsed, "task"), "task2");
assert_eq!(arg_value(&parsed, "run_args"), "c");
}
#[test]
fn test_double_dash_preserve_is_not_a_separator() {
let spec = spec_with_args([
SpecArg::builder()
.name("kept")
.var(true)
.var_max(1)
.required(false)
.double_dash(SpecDoubleDashChoices::Preserve)
.build(),
required_arg("rest"),
]);
let parsed = parse_partial(&spec, &input(&["test", "--", "x"])).unwrap();
assert_eq!(arg_value(&parsed, "kept"), "--");
assert!(parsed.args.keys().all(|a| a.name != "rest"));
assert!(!parsed.double_dash_seen);
assert_eq!(parsed.errors.len(), 1);
}
#[test]
fn test_double_dash_required_does_not_bail_in_parse_partial() {
let spec = spec_with_args([required_arg("file")]);
assert!(parse_partial(&spec, &input(&["test", "x"])).is_ok());
assert!(parse(&spec, &input(&["test", "x"])).is_err());
}
#[test]
fn test_double_dash_without_required_arg_does_not_move_cursor() {
let spec = spec_with_args([
SpecArg::builder().name("first").required(false).build(),
SpecArg::builder().name("second").required(false).build(),
]);
let parsed = parse(&spec, &input(&["test", "--", "a", "b"])).unwrap();
assert_eq!(arg_value(&parsed, "first"), "a");
assert_eq!(arg_value(&parsed, "second"), "b");
assert!(parsed.next_arg.is_none());
}
#[test]
fn test_parser_with_custom_env_for_required_arg() {
let spec = spec_with_arg(
SpecArg::builder()
.name("name")
.env("NAME")
.required(true)
.build(),
);
std::env::remove_var("NAME");
let parsed = parse_with_env(&spec, &["test"], &[("NAME", "john")])
.expect("parse should succeed with custom env");
assert_eq!(parsed.args.len(), 1);
assert_eq!(first_string_value(&parsed), "john");
}
#[test]
fn test_parser_with_custom_env_for_required_flag() {
let spec = spec_with_flag(
SpecFlag::builder()
.long("name")
.env("NAME")
.required(true)
.arg(SpecArg::builder().name("name").build())
.build(),
);
std::env::remove_var("NAME");
let parsed = parse_with_env(&spec, &["test"], &[("NAME", "jane")])
.expect("parse should succeed with custom env");
assert_eq!(parsed.flags.len(), 1);
assert_eq!(first_string_value(&parsed), "jane");
}
#[test]
fn test_flag_environment_fallbacks_preserve_declaration_order() {
let spec = spec_with_flag(
SpecFlag::builder()
.long("name")
.env("NAME")
.env_fallback("OLD_NAME")
.env_fallback("OLDER_NAME")
.deprecated_env("DEPRECATED_NAME")
.arg(SpecArg::builder().name("name").build())
.build(),
);
let parsed = parse_with_env(
&spec,
&["test"],
&[
("NAME", "canonical"),
("OLD_NAME", "fallback"),
("DEPRECATED_NAME", "deprecated"),
],
)
.unwrap();
assert_eq!(first_string_value(&parsed), "canonical");
let parsed = parse_with_env(
&spec,
&["test"],
&[("OLDER_NAME", "older"), ("OLD_NAME", "old")],
)
.unwrap();
assert_eq!(first_string_value(&parsed), "old");
let parsed =
parse_with_env(&spec, &["test"], &[("DEPRECATED_NAME", "deprecated")]).unwrap();
assert_eq!(first_string_value(&parsed), "deprecated");
}
#[test]
fn a_value_from_a_deprecated_alias_says_which_name_to_use() {
let spec = spec_with_flag(
SpecFlag::builder()
.long("name")
.env("NAME")
.deprecated_env("DEPRECATED_NAME")
.arg(SpecArg::builder().name("name").build())
.build(),
);
let parsed = parse_with_env(&spec, &["test"], &[("NAME", "canonical")]).unwrap();
assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
let parsed =
parse_with_env(&spec, &["test"], &[("DEPRECATED_NAME", "deprecated")]).unwrap();
assert_eq!(parsed.warnings.len(), 1, "{:?}", parsed.warnings);
assert_eq!(
parsed.warnings[0].kind,
crate::warn::WarningKind::DeprecatedEnv
);
assert_eq!(parsed.warnings[0].name, "DEPRECATED_NAME");
assert_eq!(parsed.warnings[0].replacement.as_deref(), Some("NAME"));
assert_eq!(first_string_value(&parsed), "deprecated");
}
#[test]
fn a_deprecated_flag_reports_only_when_it_was_used() {
let spec = spec_with_flag(
SpecFlag::builder()
.long("output")
.deprecated("use --out")
.deprecated_remove_at("3.0.0")
.arg(SpecArg::builder().name("output").build())
.build(),
);
let parsed = parse_with_env(&spec, &["test"], &[]).unwrap();
assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
let parsed = parse_with_env(&spec, &["test", "--output", "a.txt"], &[]).unwrap();
assert_eq!(parsed.warnings.len(), 1, "{:?}", parsed.warnings);
assert_eq!(
parsed.warnings[0].kind,
crate::warn::WarningKind::DeprecatedFlag
);
assert_eq!(parsed.warnings[0].name, "--output");
assert_eq!(parsed.warnings[0].remove_at.as_deref(), Some("3.0.0"));
assert_eq!(
parsed.warnings[0].render(),
"warning: --output is deprecated, removed at 3.0.0: use --out\n",
);
}
#[test]
fn a_milestone_the_spec_has_not_reached_stays_quiet() {
let flag = SpecFlag::builder()
.long("output")
.deprecated("use --out")
.deprecated_warn_at("9.0.0")
.arg(SpecArg::builder().name("output").build())
.build();
let mut spec = spec_with_flag(flag);
spec.version = Some("2.0.0".to_string());
let parsed = parse_with_env(&spec, &["test", "--output", "a.txt"], &[]).unwrap();
assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
spec.version = Some("9.0.0".to_string());
let parsed = parse_with_env(&spec, &["test", "--output", "a.txt"], &[]).unwrap();
assert_eq!(parsed.warnings.len(), 1, "{:?}", parsed.warnings);
}
#[test]
fn test_parser_with_custom_env_still_fails_when_missing() {
let spec = spec_with_arg(
SpecArg::builder()
.name("name")
.env("NAME")
.required(true)
.build(),
);
std::env::remove_var("NAME");
assert!(parse_with_env(&spec, &["test"], &[]).is_err());
}
#[test]
fn test_parser_does_not_treat_env_choice_value_as_help() {
let spec = spec_with_arg(
SpecArg::builder()
.name("env")
.env("CURRENT_ENV")
.choices(["dev", "staging"])
.required(false)
.build(),
);
assert_parse_err(
parse_with_env(&spec, &["test"], &[("CURRENT_ENV", "--help")]),
"Invalid choice for arg env: --help, expected one of dev, staging",
);
}
#[test]
fn test_parser_does_not_treat_default_choice_value_as_help() {
let spec = spec_with_flag(
SpecFlag::builder()
.long("env")
.arg(
SpecArg::builder()
.name("env")
.choices(["dev", "staging"])
.build(),
)
.default_value("--help")
.build(),
);
assert_parse_err(
parse_with_env(&spec, &["test"], &[]),
"Invalid choice for option env: --help, expected one of dev, staging",
);
}
fn words(of: &[&str]) -> Vec<String> {
of.iter().map(|s| s.to_string()).collect()
}
#[test]
fn a_command_that_needs_a_subcommand_says_so() {
let spec: Spec = r#"
name "ex"
bin "ex"
cmd "gen" subcommand_required=#true {
cmd "two" {}
cmd "one" {}
cmd "secret" hide=#true {}
alias "g"
}
cmd "open" {
cmd "sub" {}
}
"#
.parse()
.unwrap();
let err = parse(&spec, &words(&["ex", "gen"])).unwrap_err();
assert_eq!(err.to_string(), "`gen` needs a subcommand: one of one, two");
let err = parse(&spec, &words(&["ex", "g"])).unwrap_err();
assert!(err.to_string().starts_with("`gen` needs a subcommand"));
parse(&spec, &words(&["ex", "gen", "one"])).unwrap();
parse(&spec, &words(&["ex", "open"])).unwrap();
parse(&spec, &words(&["ex", "open", "sub"])).unwrap();
}
#[test]
fn arg_required_else_help_observes_the_selected_commands_argv() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--verbose" global=#true
cmd "run" arg_required_else_help=#true {
flag "--all"
}
"#
.parse()
.unwrap();
let words = |items: &[&str]| items.iter().map(|s| (*s).to_string()).collect::<Vec<_>>();
let err = parse(&spec, &words(&["ex", "run"])).unwrap_err();
assert!(err.to_string().contains("Usage: ex run"), "{err}");
let err = parse(&spec, &words(&["ex", "--verbose", "run"])).unwrap_err();
assert!(err.to_string().contains("Usage: ex run"), "{err}");
parse(&spec, &words(&["ex", "run", "--all"])).expect("run received an argv token");
}
#[test]
fn an_unmatched_word_is_forwarded_when_external_subcommand_is_set() {
let spec: Spec = r#"
name "ex"
bin "ex"
unknown_flags "error"
external_subcommand #true
cmd "install"
flag "-v --verbose" global=#true
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["ex", "foo", "--help", "bar"])).unwrap();
assert_eq!(
parsed.external,
Some(vec!["foo".into(), "--help".into(), "bar".into()])
);
assert!(parsed.flags.is_empty());
let parsed = parse(&spec, &input(&["ex", "install"])).unwrap();
assert_eq!(parsed.cmd.name, "install");
assert!(parsed.external.is_none());
let parsed = parse(&spec, &input(&["ex", "-v", "foo", "--verbose"])).unwrap();
assert_eq!(
parsed.external,
Some(vec!["foo".into(), "--verbose".into()])
);
assert!(parsed.flags.keys().any(|flag| flag.name == "verbose"));
assert!(parse(&spec, &input(&["ex", "--wat"])).is_err());
let parsed = parse(&spec, &input(&["ex", "-1", "rest"])).unwrap();
assert_eq!(parsed.external, Some(vec!["-1".into(), "rest".into()]));
}
#[test]
fn an_external_subcommand_satisfies_subcommand_required() {
let mut spec: Spec = r#"
name "ex"
bin "ex"
external_subcommand #true
cmd "install"
"#
.parse()
.unwrap();
spec.cmd.subcommand_required = true;
parse(&spec, &input(&["ex", "foo", "--help"])).unwrap();
assert!(parse(&spec, &input(&["ex"])).is_err());
}
#[test]
fn a_default_subcommand_outranks_an_external_one() {
let spec: Spec = r#"
name "ex"
bin "ex"
default_subcommand "run"
external_subcommand #true
cmd "run" {
arg "[task]"
}
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["ex", "build"])).unwrap();
assert_eq!(parsed.cmd.name, "run");
assert!(parsed.external.is_none());
assert_eq!(first_string_value(&parsed), "build");
}
#[test]
fn multicall_basename_strips_a_path_and_exe() {
assert_eq!(multicall_basename("/usr/bin/ls"), "ls");
assert_eq!(multicall_basename(r"C:\busybox\ls.exe"), "ls");
assert_eq!(multicall_basename("LS.EXE"), "LS");
assert_eq!(multicall_basename("busybox"), "busybox");
}
#[test]
fn a_multicall_applet_is_the_first_word() {
let spec: Spec = r#"
name "busybox"
bin "busybox"
multicall #true
cmd "ls" {
arg "[ARGS]" var=#true
}
cmd "cat"
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["/usr/bin/ls", "-l"])).unwrap();
assert_eq!(parsed.cmd.name, "ls");
match parsed.args.values().next() {
Some(ParseValue::MultiString(values)) => assert_eq!(values, &["-l".to_string()]),
other => panic!("expected ARGS to collect -l, got {other:?}"),
}
let parsed = parse(&spec, &input(&["/usr/bin/busybox", "ls", "-l"])).unwrap();
assert_eq!(parsed.cmd.name, "ls");
let mut configured = spec.clone();
configured.name = "BusyBox".to_string();
configured.bin = "/opt/bin/busybox.exe".to_string();
let parsed = parse(&configured, &input(&["/usr/bin/busybox.exe", "ls", "-l"])).unwrap();
assert_eq!(parsed.cmd.name, "ls");
let parsed = parse(&spec, &input(&["ls.exe"])).unwrap();
assert_eq!(parsed.cmd.name, "ls");
let mut plain = spec.clone();
plain.multicall = false;
let parsed = parse(&plain, &input(&["/usr/bin/ls", "ls"])).unwrap();
assert_eq!(parsed.cmd.name, "ls");
}
#[test]
fn a_multicall_unknown_applet_can_be_external() {
let spec: Spec = r#"
name "busybox"
bin "busybox"
multicall #true
unknown_flags "error"
external_subcommand #true
cmd "ls"
"#
.parse()
.unwrap();
let parsed = parse(&spec, &input(&["/usr/bin/git", "--help"])).unwrap();
assert_eq!(parsed.external, Some(vec!["git".into(), "--help".into()]));
let mut closed = spec.clone();
closed.cmd.external_subcommand = false;
assert!(parse(&closed, &input(&["wat"])).is_err());
}
#[cfg(feature = "unstable_choices_env")]
#[test]
fn test_parser_arg_choices_from_custom_env() {
let spec = spec_arg_choices_env("DEPLOY_ENVS");
let parsed =
parse_with_env(&spec, &["test", "bar"], &[("DEPLOY_ENVS", "foo,bar baz")]).unwrap();
assert_eq!(first_string_value(&parsed), "bar");
assert_parse_err(
parse_with_env(&spec, &["test", "prod"], &[("DEPLOY_ENVS", "foo,bar baz")]),
"Invalid choice for arg env: prod, expected one of foo, bar, baz",
);
assert_parse_err(
parse_with_env(&spec, &["test", "prod"], &[]),
"Invalid choice for arg env: prod, no choices resolved from env DEPLOY_ENVS",
);
}
#[cfg(feature = "unstable_choices_env")]
#[test]
fn test_parser_validates_flag_choices_from_custom_env() {
let spec = spec_flag_choices_env("DEPLOY_ENVS");
let parsed = parse_with_env(
&spec,
&["test", "--env", "baz"],
&[("DEPLOY_ENVS", "foo,bar baz")],
)
.unwrap();
assert_eq!(first_string_value(&parsed), "baz");
}
#[cfg(feature = "unstable_choices_env")]
#[test]
fn test_parser_revalidates_env_and_default_values_against_choices_env() {
let arg_env_spec = spec_with_arg(
SpecArg::builder()
.name("env")
.env("CURRENT_ENV")
.choices_env("DEPLOY_ENVS")
.build(),
);
assert_parse_err(
parse_with_env(
&arg_env_spec,
&["test"],
&[("CURRENT_ENV", "prod"), ("DEPLOY_ENVS", "dev,staging")],
),
"Invalid choice for arg env: prod, expected one of dev, staging",
);
let flag_default_spec = spec_with_flag(
SpecFlag::builder()
.long("env")
.arg(
SpecArg::builder()
.name("env")
.choices_env("DEPLOY_ENVS")
.build(),
)
.default_value("prod")
.build(),
);
assert_parse_err(
parse_with_env(
&flag_default_spec,
&["test"],
&[("DEPLOY_ENVS", "dev,staging")],
),
"Invalid choice for option env: prod, expected one of dev, staging",
);
}
#[test]
fn test_variadic_arg_captures_unknown_flags_from_spec_string() {
let spec: Spec = r#"
flag "-v --verbose" var=#true
arg "[database]" default="myapp_dev"
arg "[args...]"
"#
.parse()
.unwrap();
let input: Vec<String> = vec!["test", "mydb", "--host", "localhost"]
.into_iter()
.map(String::from)
.collect();
let parsed = parse(&spec, &input).unwrap();
let env = parsed.as_env();
assert_eq!(env.get("usage_database").unwrap(), "mydb");
assert_eq!(env.get("usage_args").unwrap(), "--host localhost");
}
#[test]
fn test_variadic_arg_captures_unknown_flags() {
let cmd = SpecCommand::builder()
.name("test")
.flag(SpecFlag::builder().short('v').long("verbose").build())
.arg(SpecArg::builder().name("database").required(false).build())
.arg(
SpecArg::builder()
.name("args")
.required(false)
.var(true)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input: Vec<String> = vec!["test", "mydb", "--host", "localhost"]
.into_iter()
.map(String::from)
.collect();
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 2);
let args_val = parsed
.args
.iter()
.find(|(a, _)| a.name == "args")
.unwrap()
.1;
match args_val {
ParseValue::MultiString(v) => {
assert_eq!(v, &vec!["--host".to_string(), "localhost".to_string()]);
}
_ => panic!("Expected MultiString, got {:?}", args_val),
}
}
#[test]
fn test_variadic_arg_captures_unknown_flags_with_double_dash() {
let cmd = SpecCommand::builder()
.name("test")
.flag(SpecFlag::builder().short('v').long("verbose").build())
.arg(SpecArg::builder().name("database").required(false).build())
.arg(
SpecArg::builder()
.name("args")
.required(false)
.var(true)
.build(),
)
.build();
let spec = Spec {
name: "test".to_string(),
bin: "test".to_string(),
cmd,
..Default::default()
};
let input: Vec<String> = vec!["test", "--", "mydb", "--host", "localhost"]
.into_iter()
.map(String::from)
.collect();
let parsed = parse(&spec, &input).unwrap();
assert_eq!(parsed.args.len(), 2);
let args_val = parsed
.args
.iter()
.find(|(a, _)| a.name == "args")
.unwrap()
.1;
match args_val {
ParseValue::MultiString(v) => {
assert_eq!(v, &vec!["--host".to_string(), "localhost".to_string()]);
}
_ => panic!("Expected MultiString, got {:?}", args_val),
}
}
#[test]
fn test_variadic_arg_unknown_flag_equals_value_not_split() {
let spec: Spec = r#"arg "[other_args]" var=#true"#.parse().unwrap();
let input: Vec<String> = vec!["test", "--option=3"]
.into_iter()
.map(String::from)
.collect();
let parsed = parse(&spec, &input).unwrap();
let env = parsed.as_env();
assert_eq!(
env.get("usage_other_args").map(String::as_str),
Some("'--option=3'"),
"expected a single --option=3 token, got {:?}",
env.get("usage_other_args"),
);
let input2: Vec<String> = vec!["test", "--foo=bar", "--baz=qux"]
.into_iter()
.map(String::from)
.collect();
let parsed2 = parse(&spec, &input2).unwrap();
let env2 = parsed2.as_env();
assert_eq!(
env2.get("usage_other_args").map(String::as_str),
Some("'--foo=bar' '--baz=qux'"),
"expected two intact tokens, got {:?}",
env2.get("usage_other_args"),
);
let input3: Vec<String> = vec!["test", "positional1", "--option=3", "positional2"]
.into_iter()
.map(String::from)
.collect();
let parsed3 = parse(&spec, &input3).unwrap();
let env3 = parsed3.as_env();
assert_eq!(
env3.get("usage_other_args").map(String::as_str),
Some("positional1 '--option=3' positional2"),
"expected positional args and intact flag token, got {:?}",
env3.get("usage_other_args"),
);
}
#[test]
fn test_allow_hyphen_values_consumes_short_flag_collision() {
let spec = r#"
flag "-d --working-dir <DIR>"
flag "-a --args <ARGS>" allow_hyphen_values=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "-a", "-destroy"])).unwrap();
assert_eq!(parsed.flags.len(), 1);
assert_eq!(flag_string_value(&parsed, "args"), "-destroy");
}
#[test]
fn test_allow_hyphen_values_consumes_embedded_long_value() {
let spec = r#"
flag "-d --working-dir <DIR>"
flag "-a --args <ARGS>" allow_hyphen_values=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--args=-destroy"])).unwrap();
assert_eq!(parsed.flags.len(), 1);
assert_eq!(flag_string_value(&parsed, "args"), "-destroy");
}
#[test]
fn test_allow_hyphen_values_takes_the_separator_as_its_value() {
let spec = r#"
flag "-a --args <ARGS>" allow_hyphen_values=#true
arg "[rest]..."
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "-a", "--", "-x"])).unwrap();
assert_eq!(flag_string_value(&parsed, "args"), "--");
let rest = parsed
.args
.values()
.next()
.expect("expected the word after the separator to reach the argument");
assert_eq!(rest.to_string(), "-x");
}
#[test]
fn test_variadic_allow_hyphen_values_collects_after_a_hyphenated_first_value() {
let spec = r#"
flag "-a --args <ARGS>..." allow_hyphen_values=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "-a", "-x", "b", "c"])).unwrap();
let flag = parsed
.flags
.keys()
.find(|flag| flag.name == "args")
.expect("expected args flag");
match parsed.flags.get(flag).expect("expected args value") {
ParseValue::MultiString(values) => assert_eq!(values, &["-x", "b", "c"]),
other => panic!("expected a list of values, got {other:?}"),
}
}
#[test]
fn test_variadic_allow_hyphen_values_consumes_repeated_flag_values() {
let spec = r#"
flag "-a --args <ARGS>" var=#true allow_hyphen_values=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "-a", "-val1", "-a", "-val2"])).unwrap();
let flag = parsed
.flags
.keys()
.find(|flag| flag.name == "args")
.expect("expected args flag");
let value = parsed.flags.get(flag).expect("expected args value");
match value {
ParseValue::MultiString(values) => {
assert_eq!(values, &vec!["-val1".to_string(), "-val2".to_string()]);
}
_ => panic!("expected MultiString, got {value:?}"),
}
}
#[test]
fn test_require_equals_accepts_attached_and_refuses_detached() {
let spec = r#"
flag "--inspect <PORT>" require_equals=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--inspect=9229"])).unwrap();
assert_eq!(flag_string_value(&parsed, "inspect"), "9229");
let err = parse(&spec, &input(&["test", "--inspect", "9229"])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("requires an argument") || msg.contains("inspect"),
"detached value must be refused: {msg}"
);
}
#[test]
fn boolean_flags_can_accept_attached_values_when_enabled() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--color" negate="--no-color" bool_value=#true
arg "[rest]"
"#
.parse()
.unwrap();
for (token, expected) in [
("--color", true),
("--color=true", true),
("--color=false", false),
("--no-color", false),
("--no-color=false", true),
] {
let parsed = parse(&spec, &input(&["ex", token])).unwrap();
assert!(
matches!(
parsed.flags.get(&spec.cmd.flags[0]),
Some(ParseValue::Bool(value)) if *value == expected
),
"{token}"
);
}
let parsed = parse(&spec, &input(&["ex", "--color=false", "word"])).unwrap();
assert!(matches!(
parsed.args.get(&spec.cmd.args[0]),
Some(ParseValue::String(value)) if value == "word"
));
let err = parse(&spec, &input(&["ex", "--color=maybe"])).unwrap_err();
assert!(err.to_string().contains("expected `true` or `false`"));
let strict: Spec = r#"
name "ex"
bin "ex"
args_override_self #false
flag "--color" negate="--no-color" bool_value=#true
"#
.parse()
.unwrap();
assert!(parse(&strict, &input(&["ex", "--color=false", "--color=true"])).is_err());
let parsed = parse(
&strict,
&input(&["ex", "--color=false", "--no-color=false"]),
)
.unwrap();
assert!(matches!(
parsed.flags.get(&strict.cmd.flags[0]),
Some(ParseValue::Bool(true))
));
}
#[test]
fn test_require_equals_refuses_a_detached_value_after_a_short_bundle() {
let spec = r#"
flag "-a --all"
flag "-i --inspect <PORT>" require_equals=#true
"#
.parse::<Spec>()
.unwrap();
let err = parse(&spec, &input(&["test", "-ai", "9229"])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("requires an argument") || msg.contains("inspect"),
"bundled short must refuse the following word: {msg}"
);
}
#[test]
fn test_default_missing_binds_when_the_value_is_left_off() {
let spec = r#"
flag "-c --color <WHEN>" default_missing="always"
flag "-v --verbose"
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--color"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "always");
let parsed = parse(&spec, &input(&["test", "--color=never"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "never");
let parsed = parse(&spec, &input(&["test", "--color", "never"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "never");
let parsed = parse(&spec, &input(&["test", "--color", "--verbose"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "always");
assert!(parsed.flags.keys().any(|f| f.name == "verbose"));
let parsed = parse(&spec, &input(&["test", "--color="])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "");
let parsed = parse(&spec, &input(&["test", "-cnever"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "never");
let parsed = parse(&spec, &input(&["test", "-c", "-v"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "always");
assert!(parsed.flags.keys().any(|f| f.name == "verbose"));
}
#[test]
fn test_optional_flag_value_preserves_bare_and_explicit_empty_forms() {
let spec = r#"
flag "--bump [LEVEL]" value_optional=#true
flag "--verbose"
arg "[FILE]"
"#
.parse::<Spec>()
.unwrap();
let absent = parse(&spec, &input(&["test"])).unwrap();
assert!(!absent.flags.keys().any(|flag| flag.name == "bump"));
let bare = parse(&spec, &input(&["test", "--bump", "--verbose", "file.txt"])).unwrap();
let bump = bare
.flags
.iter()
.find(|(flag, _)| flag.name == "bump")
.map(|(_, value)| value)
.unwrap();
assert!(matches!(bump, ParseValue::MultiString(values) if values.is_empty()));
assert!(bare.flags.keys().any(|flag| flag.name == "verbose"));
assert_eq!(arg_value(&bare, "FILE"), "file.txt");
let explicit = parse(&spec, &input(&["test", "--bump=", "file.txt"])).unwrap();
assert_eq!(flag_string_value(&explicit, "bump"), "");
let corrected = parse(
&spec,
&input(&["test", "--bump=2", "--bump", "--verbose", "file.txt"]),
)
.unwrap();
let bump = corrected
.flags
.iter()
.find(|(flag, _)| flag.name == "bump")
.map(|(_, value)| value)
.unwrap();
assert!(matches!(bump, ParseValue::MultiString(values) if values.is_empty()));
let collecting = r#"
flag "--tag [TAG]..." value_optional=#true
flag "--verbose"
"#
.parse::<Spec>()
.unwrap();
let valued = parse(
&collecting,
&input(&["test", "--tag", "one", "two", "--verbose"]),
)
.unwrap();
let tag = valued
.flags
.iter()
.find(|(flag, _)| flag.name == "tag")
.map(|(_, value)| value)
.unwrap();
assert!(matches!(tag, ParseValue::MultiString(values) if values == &["one", "two"]));
}
#[test]
fn test_repeatable_bare_optional_values_count_each_occurrence() {
let spec = r#"
flag "--tag [TAG]" var=#true var_min=2 var_max=2 value_optional=#true
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--tag", "--tag"])).unwrap();
let tag = parsed
.flags
.iter()
.find(|(flag, _)| flag.name == "tag")
.map(|(_, value)| value)
.unwrap();
assert!(matches!(tag, ParseValue::MultiString(values) if values == &["", ""]));
assert!(parse(&spec, &input(&["test", "--tag"])).is_err());
assert!(parse(&spec, &input(&["test", "--tag", "--tag", "--tag"])).is_err());
}
#[test]
fn test_repeatable_variadic_optional_values_do_not_gain_bare_occurrences() {
let spec = r#"
flag "--tag [TAG]..." var=#true value_optional=#true
flag "--verbose"
"#
.parse::<Spec>()
.unwrap();
for argv in [
&["test", "--tag", "one", "two"][..],
&["test", "--tag", "one", "two", "--verbose"][..],
&["test", "--tag", "one", "--tag", "two"][..],
] {
let parsed = parse(&spec, &input(argv)).unwrap();
let tag = parsed
.flags
.iter()
.find(|(flag, _)| flag.name == "tag")
.map(|(_, value)| value)
.unwrap();
assert!(
matches!(tag, ParseValue::MultiString(values) if values == &["one", "two"]),
"argv={argv:?}: {tag:?}"
);
}
let bare = parse(&spec, &input(&["test", "--tag", "--verbose"])).unwrap();
let tag = bare
.flags
.iter()
.find(|(flag, _)| flag.name == "tag")
.map(|(_, value)| value)
.unwrap();
assert!(matches!(tag, ParseValue::MultiString(values) if values == &[""]));
}
#[test]
fn test_default_missing_with_require_equals_refuses_the_following_word() {
let spec = r#"
flag "--inspect <PORT>" require_equals=#true default_missing="9229"
arg "[rest]"
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--inspect"])).unwrap();
assert_eq!(flag_string_value(&parsed, "inspect"), "9229");
let parsed = parse(&spec, &input(&["test", "--inspect=1234"])).unwrap();
assert_eq!(flag_string_value(&parsed, "inspect"), "1234");
let parsed = parse(&spec, &input(&["test", "--inspect", "80"])).unwrap();
assert_eq!(flag_string_value(&parsed, "inspect"), "9229");
assert_eq!(
parsed
.args
.values()
.next()
.map(|v| v.to_string())
.as_deref(),
Some("80")
);
let parsed = parse(&spec, &input(&["test", "--inspect="])).unwrap();
assert_eq!(flag_string_value(&parsed, "inspect"), "");
}
#[test]
fn test_default_missing_must_be_a_choice() {
let spec = r#"
flag "--color <WHEN>" default_missing="always" {
choices "auto" "always" "never"
}
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "--color"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "always");
let parsed = parse(&spec, &input(&["test", "--color=never"])).unwrap();
assert_eq!(flag_string_value(&parsed, "color"), "never");
let spec = r#"
flag "--color <WHEN>" default_missing="wat" {
choices "auto" "always" "never"
}
"#
.parse::<Spec>()
.unwrap();
let err = parse(&spec, &input(&["test", "--color"])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Invalid choice for option color: wat"),
"missing default has to pass choices the same way a typed value does: {msg}"
);
let err = parse(&spec, &input(&["test", "--color=wat"])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Invalid choice for option color: wat"),
"an attached value that is not a choice is still refused: {msg}"
);
let spec = r#"
flag "--inspect <PORT>" require_equals=#true default_missing="wat" {
choices "9229" "80"
}
arg "[rest]"
"#
.parse::<Spec>()
.unwrap();
let err = parse(&spec, &input(&["test", "--inspect", "80"])).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("Invalid choice for option inspect: wat"),
"require_equals still binds the missing string, so the error is the choice: {msg}"
);
}
#[test]
fn test_hyphen_values_still_default_to_short_flag_parsing() {
let spec = r#"
flag "-d --working-dir <DIR>"
flag "-a --args <ARGS>"
"#
.parse::<Spec>()
.unwrap();
let parsed = parse(&spec, &input(&["test", "-a", "-destroy"])).unwrap();
assert_eq!(flag_string_value(&parsed, "working-dir"), "estroy");
}
mod available_flags {
use super::*;
fn spec() -> Spec {
r#"
bin "test"
flag "-v --verbose" global=#true
flag "--raw" global=#true effect="write"
flag "--local-only"
cmd "run" {
flag "-r --raw"
flag "-w --watch"
cmd "once"
}
"#
.parse::<Spec>()
.unwrap()
}
fn chain<'a>(spec: &'a Spec, path: &[&str]) -> Vec<&'a SpecCommand> {
let mut chain = vec![&spec.cmd];
for segment in path {
chain.push(chain.last().unwrap().find_subcommand(segment).unwrap());
}
chain
}
fn names(spec: &Spec, path: &[&str]) -> Vec<String> {
let mut names: Vec<_> = available_flags(&chain(spec, path))
.iter()
.map(|f| f.name.clone())
.collect();
names.sort();
names
}
#[test]
fn an_empty_chain_yields_nothing() {
assert!(available_flags(&[]).is_empty());
}
#[test]
fn the_root_gets_its_own_flags() {
let spec = spec();
assert_eq!(names(&spec, &[]), ["local-only", "raw", "verbose"]);
}
#[test]
fn a_subcommand_keeps_globals_and_drops_local_only_ancestors() {
let spec = spec();
assert_eq!(names(&spec, &["run"]), ["raw", "verbose", "watch"]);
}
#[test]
fn a_re_declared_global_is_listed_once() {
let spec = r#"
bin "test"
flag "-y --yes" global=#true effect="write"
cmd "rm" {
flag "-y --yes"
}
"#
.parse::<Spec>()
.unwrap();
let flags = available_flags(&chain(&spec, &["rm"]));
assert_eq!(flags.len(), 1, "{flags:?}");
assert_eq!(flags[0].effect.map(|e| e.as_str()), Some("write"));
}
#[test]
fn a_re_declared_global_keeps_the_globals_declaration() {
let spec = spec();
let flags = available_flags(&chain(&spec, &["run"]));
let raw = flags.iter().find(|f| f.name == "raw").unwrap();
assert!(raw.global);
assert_eq!(raw.effect.map(|e| e.as_str()), Some("write"));
assert_eq!(raw.short, ['r']);
}
#[test]
fn it_matches_what_a_parse_accepts() {
let spec = spec();
for path in [vec![], vec!["run"], vec!["run", "once"]] {
let argv = std::iter::once("test".to_string())
.chain(path.iter().map(|s| s.to_string()))
.collect::<Vec<_>>();
let parsed = parse_partial(&spec, &argv).unwrap();
let mut from_parse: Vec<_> = unique_flags(parsed.available_flags.values())
.map(|f| f.name.clone())
.collect();
from_parse.sort();
assert_eq!(names(&spec, &path), from_parse, "path {path:?}");
}
}
}
fn roles(parsed: &ParseOutput, index: usize) -> Vec<String> {
parsed
.tokens
.iter()
.find(|token| token.index == index)
.unwrap_or_else(|| panic!("no token at {index}"))
.roles
.iter()
.map(render_role)
.collect()
}
fn origins(parsed: &ParseOutput, flag: &str) -> Vec<ValueOrigin> {
parsed
.flag_origins
.iter()
.find(|(f, _)| f.name == flag)
.map(|(_, origins)| origins.clone())
.unwrap_or_default()
}
fn explain_with_env(spec: &Spec, words: &[&str], env: &[(&str, &str)]) -> ParseOutput {
let env = env
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
Parser::new(spec)
.with_env(env)
.explain(&input(words))
.unwrap()
}
fn explain(spec: &Spec, words: &[&str]) -> ParseOutput {
explain_with_env(spec, words, &[])
}
#[test]
fn an_attached_long_value_is_recorded_on_the_flag_token() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--env <env>\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--env=prod"]);
assert_eq!(roles(&parsed, 0), ["program"]);
assert_eq!(
roles(&parsed, 1),
["flag env as --env", "value of env = [\"prod\"], attached"]
);
assert!(origins(&parsed, "env").is_empty(), "typed, so no fallback");
}
#[test]
fn a_detached_long_value_is_recorded_on_its_own_token() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--env <env>\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--env", "prod"]);
assert_eq!(roles(&parsed, 1), ["flag env as --env"]);
assert_eq!(roles(&parsed, 2), ["value of env = [\"prod\"]"]);
}
#[test]
fn a_short_bundle_is_attributed_to_the_bundle_token() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"-a\"\nflag \"-b\"\nflag \"-j <n>\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "-abj8"]);
assert_eq!(
roles(&parsed, 1),
[
"flag a as -a",
"flag b as -b",
"flag j as -j",
"value of j = [\"8\"], attached",
]
);
assert_eq!(parsed.tokens.len(), 2);
}
#[test]
fn a_delimiter_splits_one_token_into_several_values() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--tags <tags>...\" delimiter=\",\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--tags", "a,b,c"]);
assert_eq!(
roles(&parsed, 2),
["value of tags = [\"a\", \"b\", \"c\"]"],
"the values meant, not the word typed"
);
}
#[test]
fn a_separator_and_the_words_after_it_are_distinguished() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\narg \"<src>\"\narg \"[raw]...\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "a", "--", "-x"]);
assert_eq!(roles(&parsed, 1), ["arg src = [\"a\"]"]);
assert_eq!(roles(&parsed, 2), ["separator"]);
assert_eq!(roles(&parsed, 3), ["arg raw = [\"-x\"]"]);
}
#[test]
fn a_second_separator_is_data() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\narg \"[raw]...\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--", "a", "--", "b"]);
assert_eq!(roles(&parsed, 1), ["separator"]);
assert_eq!(roles(&parsed, 3), ["arg raw = [\"--\"]"]);
}
#[test]
fn an_unknown_flag_says_what_took_it() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\narg \"[rest]...\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--wat"]);
assert_eq!(roles(&parsed, 1), ["unknown flag, bound as rest"]);
}
#[test]
fn a_subcommand_word_is_not_a_positional() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\ncmd \"build\" {\n arg \"<target>\"\n}\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "build", "a"]);
assert_eq!(roles(&parsed, 1), ["subcommand build"]);
assert_eq!(roles(&parsed, 2), ["arg target = [\"a\"]"]);
}
#[test]
fn a_multicall_applet_is_read_at_argv0() {
let spec: Spec =
"name \"box\"\nbin \"box\"\nmulticall #true\ncmd \"ls\" {\n flag \"-l\"\n}\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["/usr/bin/ls", "-l"]);
assert_eq!(roles(&parsed, 0), ["program", "subcommand ls"]);
assert!(parsed.tokens[0].synthesized);
assert_eq!(parsed.tokens[0].word, "/usr/bin/ls");
}
#[test]
fn words_the_parse_never_reached_say_so() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\narg \"[rest]...\"\n"
.parse()
.unwrap();
let parsed = Parser::new(&spec)
.explain(&input(&["ex", "--help", "a"]))
.unwrap();
assert_eq!(roles(&parsed, 2), ["unread"]);
}
#[test]
fn an_env_origin_names_the_variable_that_fired() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--token <t>\" env=\"EX_TOKEN\" env_fallback=\"EX_TOKEN_OLD\"\n"
.parse()
.unwrap();
let primary = explain_with_env(&spec, &["ex"], &[("EX_TOKEN", "a")]);
assert_eq!(
origins(&primary, "token"),
[ValueOrigin::Env("EX_TOKEN".to_string())]
);
let fallback = explain_with_env(&spec, &["ex"], &[("EX_TOKEN_OLD", "b")]);
assert_eq!(
origins(&fallback, "token"),
[ValueOrigin::Env("EX_TOKEN_OLD".to_string())]
);
}
#[test]
fn a_default_origin_is_recorded_for_flags_and_args() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--color <when>\" default=\"auto\"\narg \"[src]\" default=\".\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex"]);
assert_eq!(origins(&parsed, "color"), [ValueOrigin::Default]);
let (arg, origins) = parsed.arg_origins.iter().next().unwrap();
assert_eq!(arg.name, "src");
assert_eq!(origins, &[ValueOrigin::Default]);
}
#[test]
fn a_default_if_origin_carries_the_condition_that_fired() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--profile <p>"
flag "--strict" {
default_if "--profile" "prod" "true"
}
"#
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--profile", "prod"]);
assert_eq!(
origins(&parsed, "strict"),
[ValueOrigin::DefaultIf {
selector: "--profile".to_string(),
when: Some("prod".to_string()),
}]
);
}
#[test]
fn a_bare_optional_value_flag_records_default_missing() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--color <when>\" default_missing=\"always\"\nflag \"-v\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--color", "-v"]);
assert_eq!(roles(&parsed, 1), ["flag color as --color"]);
assert_eq!(origins(&parsed, "color"), [ValueOrigin::DefaultMissing]);
assert_eq!(roles(&parsed, 2), ["flag v as -v"]);
}
#[test]
fn a_var_flag_can_take_one_value_from_argv_and_one_from_default_missing() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--color <when>\" var=#true default_missing=\"always\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--color=red", "--color"]);
assert_eq!(
roles(&parsed, 1),
[
"flag color as --color",
"value of color = [\"red\"], attached"
]
);
assert_eq!(origins(&parsed, "color"), [ValueOrigin::DefaultMissing]);
}
#[test]
fn an_override_names_the_flag_that_did_it() {
let spec: Spec =
"name \"ex\"\nbin \"ex\"\nflag \"--quiet\" default=\"true\"\nflag \"--loud\" overrides=\"--quiet\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--loud"]);
assert_eq!(parsed.overridden_flags.get("quiet").unwrap(), "loud");
assert!(origins(&parsed, "quiet").is_empty());
}
#[test]
fn a_restart_token_leaves_the_tokens_and_clears_the_arg_origins() {
let spec: Spec = r#"
name "ex"
bin "ex"
cmd "run" restart_token=":::" {
arg "<task>" default="build"
}
"#
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "run", "lint", ":::", "test"]);
assert_eq!(roles(&parsed, 2), ["arg task = [\"lint\"]"]);
assert_eq!(roles(&parsed, 3), ["restart"]);
assert_eq!(roles(&parsed, 4), ["arg task = [\"test\"]"]);
assert!(parsed.arg_origins.is_empty());
}
#[test]
fn a_value_terminator_says_which_run_it_ended() {
let spec: Spec = r#"
name "ex"
bin "ex"
flag "--exec <cmd>..." value_terminator=";"
arg "<src>"
"#
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "--exec", "rm", "tmp", ";", "a"]);
assert_eq!(roles(&parsed, 3), ["value of exec = [\"tmp\"]"]);
assert_eq!(roles(&parsed, 4), ["value terminator, ends exec"]);
assert_eq!(roles(&parsed, 5), ["arg src = [\"a\"]"]);
}
#[test]
fn an_args_value_terminator_says_which_run_it_ended() {
let spec: Spec = r#"
name "ex"
bin "ex"
arg "<files>..." value_terminator=";"
arg "[dest]"
"#
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "a", "b", ";", "out"]);
assert_eq!(roles(&parsed, 2), ["arg files = [\"b\"]"]);
assert_eq!(roles(&parsed, 3), ["value terminator, ends files"]);
assert_eq!(roles(&parsed, 4), ["arg dest = [\"out\"]"]);
}
#[test]
fn explain_keeps_the_bindings_of_a_command_line_that_fails() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nflag \"--env <env>\"\narg \"<src>\"\n"
.parse()
.unwrap();
let parsed = Parser::new(&spec)
.explain(&input(&["ex", "--env=prod"]))
.unwrap();
assert!(Parser::new(&spec)
.parse(&input(&["ex", "--env=prod"]))
.is_err());
assert_eq!(
roles(&parsed, 1),
["flag env as --env", "value of env = [\"prod\"], attached"]
);
assert!(
parsed.errors.iter().any(|e| e.to_string().contains("src")),
"{:?}",
parsed.errors
);
}
#[test]
fn an_external_subcommand_forwards_whole_tokens() {
let spec: Spec = "name \"ex\"\nbin \"ex\"\nexternal_subcommand #true\ncmd \"build\"\n"
.parse()
.unwrap();
let parsed = explain(&spec, &["ex", "deploy", "--now"]);
assert_eq!(roles(&parsed, 1), ["external"]);
assert_eq!(roles(&parsed, 2), ["external"]);
}
#[test]
fn a_view_keeps_the_callers_argv_positions() {
let spec: Spec = r#"
bin "ex"
view "runner" root="run"
cmd "run" {
flag "--token <token>"
}
"#
.parse()
.unwrap();
let parsed = explain(&spec, &["runner", "--token", "secret"]);
assert_eq!(roles(&parsed, 0), ["program"]);
assert_eq!(roles(&parsed, 2), ["value of token = [\"secret\"]"]);
}
}