use std::hash::RandomState;
use std::string::{String, ToString};
use std::vec::Vec;
use heck::ToKebabCase;
use indexmap::IndexMap;
use crate::config_value::{ConfigValue, EnumValue, Sourced};
use crate::driver::{Diagnostic, HelpListMode, LayerOutput, Severity};
use crate::provenance::Provenance;
use crate::schema::{ArgKind, ArgLevelSchema, ArgSchema, Schema, Subcommand};
use crate::value_builder::{LeafValue, ValueBuilder};
#[derive(Debug, Clone, Default)]
pub enum CliArgsSource {
#[default]
FromStdEnv,
Explicit(Vec<String>),
}
#[derive(Debug, Clone, Default)]
pub struct CliConfig {
args: CliArgsSource,
strict: bool,
}
impl CliConfig {
pub fn strict(&self) -> bool {
self.strict
}
pub fn resolve_args(&self) -> Vec<String> {
match &self.args {
CliArgsSource::FromStdEnv => std::env::args().skip(1).collect(),
CliArgsSource::Explicit(args) => args.clone(),
}
}
}
#[derive(Debug, Default)]
pub struct CliConfigBuilder {
config: CliConfig,
}
impl CliConfigBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.config.args = CliArgsSource::Explicit(args.into_iter().map(|s| s.into()).collect());
self
}
pub fn args_os<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
self.config.args = CliArgsSource::Explicit(
args.into_iter()
.filter_map(|s| s.as_ref().to_str().map(|s| s.to_string()))
.collect(),
);
self
}
pub fn strict(mut self) -> Self {
self.config.strict = true;
self
}
pub fn build(self) -> CliConfig {
self.config
}
}
pub fn parse_cli(schema: &Schema, cli_config: &CliConfig) -> LayerOutput {
let args = cli_config.resolve_args();
let args: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let mut ctx = ParseContext::new(&args, schema);
ctx.parse();
ctx.into_output()
}
struct CountedAccumulator {
count: u64,
}
struct ParentLevel<'a> {
args: &'a ArgLevelSchema,
result: IndexMap<String, ConfigValue, RandomState>,
counted: IndexMap<String, CountedAccumulator, RandomState>,
}
#[derive(Clone, Copy)]
enum InsertTarget {
Current,
Parent(usize),
}
#[derive(Clone, Copy)]
struct FlagValueMode {
is_bool: bool,
is_multiple: bool,
}
struct ParentFlagLookup {
parent_idx: usize,
effective_name: String,
is_bool: bool,
is_counted: bool,
is_multiple: bool,
}
struct ParseContext<'a> {
args: &'a [&'a str],
index: usize,
schema: &'a Schema,
result: IndexMap<String, ConfigValue, RandomState>,
diagnostics: Vec<Diagnostic>,
positional_only: bool,
counted: IndexMap<String, CountedAccumulator, RandomState>,
arg_offsets: Vec<usize>,
parent_stack: Vec<ParentLevel<'a>>,
config_builder: Option<ValueBuilder<'a>>,
config_file_path: Option<camino::Utf8PathBuf>,
help_list_mode: Option<HelpListMode>,
}
impl<'a> ParseContext<'a> {
fn new(args: &'a [&'a str], schema: &'a Schema) -> Self {
let mut arg_offsets = Vec::with_capacity(args.len());
let mut offset = 0;
for (i, arg) in args.iter().enumerate() {
arg_offsets.push(offset);
offset += arg.len();
if i < args.len() - 1 {
offset += 1; }
}
let config_builder = schema.config().map(ValueBuilder::new);
Self {
args,
index: 0,
schema,
result: IndexMap::default(),
diagnostics: Vec::new(),
positional_only: false,
counted: IndexMap::default(),
arg_offsets,
parent_stack: Vec::new(),
config_builder,
config_file_path: None,
help_list_mode: None,
}
}
fn span_for_arg(&self, arg_index: usize) -> facet_reflect::Span {
let offset = self.arg_offsets.get(arg_index).copied().unwrap_or(0);
let len = self.args.get(arg_index).map(|s| s.len()).unwrap_or(0);
facet_reflect::Span::new(offset, len)
}
fn current_span(&self) -> facet_reflect::Span {
self.span_for_arg(self.index)
}
fn span_within_current(&self, sub_offset: usize, sub_len: usize) -> facet_reflect::Span {
let base = self.arg_offsets.get(self.index).copied().unwrap_or(0);
facet_reflect::Span::new(base + sub_offset, sub_len)
}
fn union_span(a: facet_reflect::Span, b: facet_reflect::Span) -> facet_reflect::Span {
let start = (a.offset as usize).min(b.offset as usize);
let end = ((a.offset + a.len) as usize).max((b.offset + b.len) as usize);
facet_reflect::Span::new(start, end - start)
}
fn parse(&mut self) {
self.parse_level(self.schema.args());
self.apply_counted_fields();
}
fn parse_level(&mut self, level: &'a ArgLevelSchema) {
while self.index < self.args.len() {
let arg = self.args[self.index];
if arg == "--" {
self.positional_only = true;
self.index += 1;
continue;
}
if self.positional_only {
if !self.try_parse_positional(level) && !self.try_parse_subcommand(level) {
let suggestion = self.suggest_subcommand_if_expected(arg, level);
self.emit_error(format!(
"unexpected positional argument: {}{}",
arg, suggestion
));
self.index += 1;
}
continue;
}
if arg.starts_with("--") {
self.parse_long_flag(arg, level);
} else if arg.starts_with('-') && arg.len() > 1 {
self.parse_short_flag(arg, level);
} else {
if !self.try_parse_subcommand(level) && !self.try_parse_positional(level) {
let suggestion = self.suggest_subcommand_if_expected(arg, level);
self.emit_error(format!("unexpected argument: {}{}", arg, suggestion));
self.index += 1;
}
}
}
}
fn parse_long_flag(&mut self, arg: &str, level: &ArgLevelSchema) {
let flag = &arg[2..];
if flag.starts_with('-') {
self.emit_error(format!("unknown flag: {}", arg));
self.index += 1;
return;
}
let (flag_name, inline_value) = if let Some(eq_pos) = flag.find('=') {
(&flag[..eq_pos], Some(&flag[eq_pos + 1..]))
} else {
(flag, None)
};
if let Some(config_schema) = self.schema.config()
&& let Some(config_field_name) = config_schema.field_name()
&& flag_name.starts_with(config_field_name)
&& flag_name.len() > config_field_name.len()
&& flag_name.as_bytes()[config_field_name.len()] == b'.'
{
self.parse_config_override(arg, flag_name, inline_value, config_field_name);
return;
}
if let Some(config_schema) = self.schema.config()
&& let Some(config_field_name) = config_schema.field_name()
&& flag_name == config_field_name.to_kebab_case()
{
self.parse_config_file_path(arg, inline_value);
return;
}
if let Some((effective_name, arg_schema)) = level.args().get(flag_name) {
if let ArgKind::Named { counted, .. } = arg_schema.kind()
&& *counted
{
self.increment_counted(effective_name);
self.index += 1;
return;
}
self.parse_flag_value(arg, effective_name, arg_schema, inline_value);
} else if let Some(lookup) = self.find_long_flag_in_parents(flag_name) {
let target = InsertTarget::Parent(lookup.parent_idx);
if lookup.is_counted {
self.increment_counted_to(target, &lookup.effective_name);
self.index += 1;
return;
}
self.parse_flag_value_simple(
arg,
target,
&lookup.effective_name,
FlagValueMode {
is_bool: lookup.is_bool,
is_multiple: lookup.is_multiple,
},
None, inline_value,
);
} else {
let all_flags: Vec<&str> = level
.args()
.iter()
.filter_map(|(name, schema)| {
if matches!(schema.kind(), ArgKind::Named { .. }) {
Some(name.as_str())
} else {
None
}
})
.collect();
let suggestion = crate::suggest::suggest_flag(flag_name, all_flags);
self.emit_error(format!("unknown flag: --{}{}", flag_name, suggestion));
self.index += 1;
}
}
fn parse_short_flag(&mut self, arg: &str, level: &ArgLevelSchema) {
let flag_part = &arg[1..];
if let Some(eq_pos) = flag_part.find('=') {
let flag_char = flag_part[..eq_pos].chars().next();
if eq_pos == 1 {
if let Some(ch) = flag_char {
let value_str = &flag_part[eq_pos + 1..];
self.parse_short_flag_with_value(ch, value_str, level);
self.index += 1;
return;
}
}
}
let chars: Vec<char> = flag_part.chars().collect();
for (i, ch) in chars.iter().enumerate() {
let found = level.args().iter().find(|(_, schema)| {
if let ArgKind::Named { short: Some(s), .. } = schema.kind() {
*s == *ch
} else {
false
}
});
let (target, name, is_bool, is_counted, is_multiple) = if let Some((name, arg_schema)) =
found
{
let is_counted = matches!(arg_schema.kind(), ArgKind::Named { counted: true, .. });
let is_bool = arg_schema
.value()
.inner_if_option()
.is_bool_or_vec_of_bool();
let is_multiple = arg_schema.multiple();
(
InsertTarget::Current,
name.to_string(),
is_bool,
is_counted,
is_multiple,
)
} else if let Some(lookup) = self.find_short_flag_in_parents(*ch) {
(
InsertTarget::Parent(lookup.parent_idx),
lookup.effective_name,
lookup.is_bool,
lookup.is_counted,
lookup.is_multiple,
)
} else {
self.emit_error(format!("unknown flag: -{}", ch));
continue;
};
if is_counted {
self.increment_counted_to(target, &name);
continue;
}
let is_last = i == chars.len() - 1;
if is_bool {
let prov = Provenance::cli(format!("-{}", ch), "true");
self.insert_value_to(
target,
&name,
ConfigValue::Bool(Sourced {
value: true,
span: None,
provenance: Some(prov),
}),
is_multiple,
);
} else if is_last {
let flag_span = self.current_span(); self.index += 1;
if self.index < self.args.len() {
let value_span = self.current_span();
let value_str = self.args[self.index];
let prov_arg = format!("-{}", ch);
let value = self.parse_value_string(value_str, &prov_arg, value_span);
self.insert_value_to(target, &name, value, is_multiple);
} else {
self.emit_error_at(format!("flag -{} requires a value", ch), flag_span);
}
} else {
let rest: String = chars[i + 1..].iter().collect();
let value_span = self.span_within_current(i + 1, rest.len());
let prov_arg = format!("-{}", ch);
let value = self.parse_value_string(&rest, &prov_arg, value_span);
self.insert_value_to(target, &name, value, is_multiple);
break; }
}
self.index += 1;
}
fn parse_short_flag_with_value(&mut self, ch: char, value_str: &str, level: &ArgLevelSchema) {
let found = level.args().iter().find(|(_, schema)| {
if let ArgKind::Named { short: Some(s), .. } = schema.kind() {
*s == ch
} else {
false
}
});
let (target, name, is_bool, is_counted, is_multiple) =
if let Some((name, arg_schema)) = found {
let is_counted = matches!(arg_schema.kind(), ArgKind::Named { counted: true, .. });
let is_bool = arg_schema.value().inner_if_option().is_bool();
let is_multiple = arg_schema.multiple();
(
InsertTarget::Current,
name.to_string(),
is_bool,
is_counted,
is_multiple,
)
} else if let Some(lookup) = self.find_short_flag_in_parents(ch) {
(
InsertTarget::Parent(lookup.parent_idx),
lookup.effective_name,
lookup.is_bool,
lookup.is_counted,
lookup.is_multiple,
)
} else {
self.emit_error(format!("unknown flag: -{}", ch));
return;
};
if is_counted {
self.increment_counted_to(target, &name);
return;
}
let prov_arg = format!("-{}", ch);
if is_bool {
let value = matches!(
value_str.to_lowercase().as_str(),
"true" | "yes" | "1" | "on" | ""
);
let prov = Provenance::cli(&prov_arg, value.to_string());
self.insert_value_to(
target,
&name,
ConfigValue::Bool(Sourced {
value,
span: None,
provenance: Some(prov),
}),
is_multiple,
);
} else {
let value_span = self.span_within_current(3, value_str.len());
let value = self.parse_value_string(value_str, &prov_arg, value_span);
self.insert_value_to(target, &name, value, is_multiple);
}
}
fn parse_flag_value(
&mut self,
arg: &str,
name: &str,
schema: &ArgSchema,
inline_value: Option<&str>,
) {
let is_bool = schema.value().inner_if_option().is_bool();
let is_multiple = schema.multiple();
let enum_variants = schema.value().inner_if_option().enum_variants();
self.parse_flag_value_simple(
arg,
InsertTarget::Current,
name,
FlagValueMode {
is_bool,
is_multiple,
},
enum_variants,
inline_value,
);
}
fn parse_flag_value_simple(
&mut self,
arg: &str,
target: InsertTarget,
name: &str,
mode: FlagValueMode,
enum_variants: Option<&[String]>,
inline_value: Option<&str>,
) {
if mode.is_bool {
let flag_span = self.current_span();
let value = if let Some(v) = inline_value {
matches!(v.to_lowercase().as_str(), "true" | "yes" | "1" | "on" | "")
} else {
true
};
let prov = Provenance::cli(arg, value.to_string());
self.insert_value_to_with_span(
target,
name,
ConfigValue::Bool(Sourced {
value,
span: None,
provenance: Some(prov),
}),
mode.is_multiple,
Some(flag_span),
);
self.index += 1;
} else {
let flag_span = self.current_span();
let (value_str, value_span) = if let Some(v) = inline_value {
let eq_pos = arg.find('=').unwrap_or(0) + 1;
let span = self.span_within_current(eq_pos, v.len());
self.index += 1;
(v, span)
} else {
self.index += 1;
if self.index < self.args.len() {
let span = self.current_span();
let v = self.args[self.index];
self.index += 1;
(v, span)
} else {
let error_msg = if let Some(variants) = enum_variants {
let variant_list = variants.join(", ");
format!("flag {} requires one of: {}", arg, variant_list)
} else {
format!("flag {} requires a value", arg)
};
self.emit_error_at(error_msg, flag_span);
return;
}
};
let prov_arg = arg.split('=').next().unwrap_or(arg);
let value = self.parse_value_string(value_str, prov_arg, value_span);
let duplicate_span = Self::union_span(flag_span, value_span);
self.insert_value_to_with_span(
target,
name,
value,
mode.is_multiple,
Some(duplicate_span),
);
}
}
fn parse_config_override(
&mut self,
_arg: &str,
flag_name: &str,
inline_value: Option<&str>,
config_field_name: &str,
) {
let path_str = &flag_name[config_field_name.len() + 1..];
let parts: Vec<&str> = path_str.split('.').collect();
let flag_span = self.current_span(); let (value_str, value_span) = if let Some(v) = inline_value {
let eq_pos = flag_name.len() + 3 + 1; let span = self.span_within_current(eq_pos, v.len());
self.index += 1;
(v, span)
} else {
self.index += 1;
if self.index < self.args.len() {
let span = self.current_span();
let v = self.args[self.index];
self.index += 1;
(v, span)
} else {
self.emit_error_at(format!("flag --{} requires a value", flag_name), flag_span);
return;
}
};
let prov_arg = format!("--{}", flag_name);
let provenance = Provenance::cli(&prov_arg, value_str);
let path: Vec<String> = parts.iter().map(|s| (*s).to_string()).collect();
let leaf_value = LeafValue::String(value_str.to_string());
self.config_builder
.as_mut()
.expect("config_builder must exist when parsing config overrides")
.set(&path, leaf_value, Some(value_span), provenance);
}
fn parse_config_file_path(&mut self, arg: &str, inline_value: Option<&str>) {
let flag_span = self.current_span();
let path_str = if let Some(v) = inline_value {
self.index += 1;
v.to_string()
} else {
self.index += 1;
if self.index < self.args.len() {
let v = self.args[self.index];
self.index += 1;
v.to_string()
} else {
self.emit_error_at(format!("flag {} requires a file path", arg), flag_span);
return;
}
};
self.config_file_path = Some(camino::Utf8PathBuf::from(path_str));
}
fn try_parse_subcommand(&mut self, level: &'a ArgLevelSchema) -> bool {
let arg = self.args[self.index];
if let Some(field_name) = level.subcommand_field_name() {
let subcommand = level
.subcommands()
.iter()
.find(|(name, _)| name.to_kebab_case() == arg);
if let Some((_, subcommand)) = subcommand {
self.index += 1;
let fields = self.parse_subcommand_args(level, subcommand);
let _ = subcommand.is_flattened_tuple();
let enum_value = ConfigValue::Enum(Sourced {
value: EnumValue {
variant: subcommand.effective_name().to_string(),
fields,
},
span: None,
provenance: Some(Provenance::cli(arg, "")),
});
self.result.insert(field_name.to_string(), enum_value);
return true;
}
}
if self.try_parse_help_pseudo_subcommand(level) {
return true;
}
false
}
fn try_parse_help_pseudo_subcommand(&mut self, level: &ArgLevelSchema) -> bool {
let arg = self.args[self.index];
if arg != "help" {
return false;
}
if level
.subcommands()
.keys()
.any(|name| name.to_kebab_case() == "help")
{
return false;
}
let list_mode = self.parse_help_list_mode();
if let Some((effective_name, arg_schema)) = level.args().get("help")
&& matches!(arg_schema.kind(), ArgKind::Named { .. })
&& arg_schema.value().inner_if_option().is_bool()
{
if let Some(mode) = list_mode {
self.help_list_mode = Some(mode);
}
self.parse_flag_value_simple(
arg,
InsertTarget::Current,
effective_name,
FlagValueMode {
is_bool: true,
is_multiple: arg_schema.multiple(),
},
None,
None,
);
if list_mode.is_some() {
self.index += if list_mode == Some(HelpListMode::Short) {
2
} else {
1
};
}
return true;
}
if let Some(lookup) = self.find_long_flag_in_parents("help")
&& lookup.is_bool
{
if let Some(mode) = list_mode {
self.help_list_mode = Some(mode);
}
self.parse_flag_value_simple(
arg,
InsertTarget::Parent(lookup.parent_idx),
&lookup.effective_name,
FlagValueMode {
is_bool: true,
is_multiple: lookup.is_multiple,
},
None,
None,
);
if list_mode.is_some() {
self.index += if list_mode == Some(HelpListMode::Short) {
2
} else {
1
};
}
return true;
}
false
}
fn parse_help_list_mode(&self) -> Option<HelpListMode> {
let next = self.args.get(self.index + 1)?;
if *next != "list" {
return None;
}
match self.args.get(self.index + 2) {
Some(flag) if *flag == "--short" => Some(HelpListMode::Short),
_ => Some(HelpListMode::Full),
}
}
fn parse_subcommand_args(
&mut self,
parent_level: &'a ArgLevelSchema,
subcommand: &'a Subcommand,
) -> IndexMap<String, ConfigValue, RandomState> {
let parent = ParentLevel {
args: parent_level,
result: core::mem::take(&mut self.result),
counted: core::mem::take(&mut self.counted),
};
self.parent_stack.push(parent);
self.parse_level(subcommand.args());
self.apply_counted_fields();
let mut parent = self
.parent_stack
.pop()
.expect("parent stack should not be empty");
let subcommand_result = core::mem::replace(&mut self.result, parent.result);
self.apply_counted_from(&mut parent.counted);
subcommand_result
}
fn apply_counted_from(
&mut self,
counted: &mut IndexMap<String, CountedAccumulator, RandomState>,
) {
for (name, acc) in counted.drain(..) {
let prov =
Provenance::cli(format!("-{} (x{})", name, acc.count), acc.count.to_string());
self.result.insert(
name,
ConfigValue::Integer(Sourced {
value: acc.count as i64,
span: None,
provenance: Some(prov),
}),
);
}
}
fn find_long_flag_in_parents(&self, flag_name: &str) -> Option<ParentFlagLookup> {
for (idx, parent) in self.parent_stack.iter().enumerate().rev() {
if let Some((effective_name, arg_schema)) = parent.args.args().get(flag_name) {
let is_counted = matches!(arg_schema.kind(), ArgKind::Named { counted: true, .. });
let is_bool = arg_schema.value().inner_if_option().is_bool();
let is_multiple = arg_schema.multiple();
return Some(ParentFlagLookup {
parent_idx: idx,
effective_name: effective_name.to_string(),
is_bool,
is_counted,
is_multiple,
});
}
}
None
}
fn find_short_flag_in_parents(&self, ch: char) -> Option<ParentFlagLookup> {
for (idx, parent) in self.parent_stack.iter().enumerate().rev() {
for (name, schema) in parent.args.args().iter() {
if let ArgKind::Named { short: Some(s), .. } = schema.kind()
&& *s == ch
{
let is_counted = matches!(schema.kind(), ArgKind::Named { counted: true, .. });
let is_bool = schema.value().inner_if_option().is_bool();
let is_multiple = schema.multiple();
return Some(ParentFlagLookup {
parent_idx: idx,
effective_name: name.to_string(),
is_bool,
is_counted,
is_multiple,
});
}
}
}
None
}
fn suggest_subcommand_if_expected(&self, arg: &str, level: &ArgLevelSchema) -> String {
if level.subcommand_field_name().is_none() || level.subcommands().is_empty() {
return String::new();
}
let subcommand_names: Vec<String> = level
.subcommands()
.iter()
.map(|(name, _)| name.to_kebab_case())
.collect();
crate::suggest::suggest_subcommand(arg, subcommand_names.iter().map(|s| s.as_str()))
}
fn try_parse_positional(&mut self, level: &ArgLevelSchema) -> bool {
let arg = self.args[self.index];
for (name, schema) in level.args() {
if !matches!(schema.kind(), ArgKind::Positional) {
continue;
}
if self.has_value(name) && !schema.multiple() {
continue;
}
let value_span = self.current_span();
let value = self.parse_value_string(arg, arg, value_span);
self.insert_value(name, value, schema.multiple());
self.index += 1;
return true;
}
false
}
fn parse_value_string(
&self,
s: &str,
arg_name: &str,
span: facet_reflect::Span,
) -> ConfigValue {
let prov = Some(Provenance::cli(arg_name, s));
ConfigValue::String(Sourced {
value: s.to_string(),
span: Some(span),
provenance: prov,
})
}
fn insert_value(&mut self, name: &str, value: ConfigValue, is_multiple: bool) {
self.insert_value_to(InsertTarget::Current, name, value, is_multiple);
}
fn insert_value_to(
&mut self,
target: InsertTarget,
name: &str,
value: ConfigValue,
is_multiple: bool,
) {
self.insert_value_to_with_span(target, name, value, is_multiple, None);
}
fn insert_value_to_with_span(
&mut self,
target: InsertTarget,
name: &str,
value: ConfigValue,
is_multiple: bool,
duplicate_span: Option<facet_reflect::Span>,
) {
let mut duplicate_non_multiple = false;
let result_map = match target {
InsertTarget::Current => &mut self.result,
InsertTarget::Parent(idx) => &mut self.parent_stack[idx].result,
};
match result_map.entry(name.to_string()) {
indexmap::map::Entry::Vacant(entry) => {
if is_multiple {
let wrapper_span = value.span();
let wrapper_provenance = value.provenance().cloned();
entry.insert(ConfigValue::Array(Sourced {
value: vec![value],
span: wrapper_span,
provenance: wrapper_provenance,
}));
} else {
entry.insert(value);
}
}
indexmap::map::Entry::Occupied(mut entry) => {
if is_multiple {
let existing = entry.get_mut();
if let ConfigValue::Array(arr) = existing {
if arr.span.is_none() {
arr.span = value.span();
}
if arr.provenance.is_none() {
arr.provenance = value.provenance().cloned();
}
arr.value.push(value);
} else {
let placeholder = ConfigValue::Null(Sourced {
value: (),
span: None,
provenance: None,
});
let old = core::mem::replace(existing, placeholder);
let wrapper_span = old.span().or(value.span());
let wrapper_provenance = old
.provenance()
.cloned()
.or_else(|| value.provenance().cloned());
*existing = ConfigValue::Array(Sourced {
value: vec![old, value],
span: wrapper_span,
provenance: wrapper_provenance,
});
}
} else {
duplicate_non_multiple = true;
}
}
}
if duplicate_non_multiple {
let message = format!(
"argument --{} was provided multiple times, but only one value is allowed",
name.to_kebab_case()
);
if let Some(span) = duplicate_span {
self.emit_error_at(message, span);
} else {
self.emit_error(message);
}
}
}
fn has_value(&self, name: &str) -> bool {
self.result.contains_key(name)
}
fn increment_counted(&mut self, name: &str) {
self.increment_counted_to(InsertTarget::Current, name);
}
fn increment_counted_to(&mut self, target: InsertTarget, name: &str) {
let counted_map = match target {
InsertTarget::Current => &mut self.counted,
InsertTarget::Parent(idx) => &mut self.parent_stack[idx].counted,
};
let acc = counted_map
.entry(name.to_string())
.or_insert_with(|| CountedAccumulator { count: 0 });
acc.count += 1;
}
fn apply_counted_fields(&mut self) {
for (name, acc) in &self.counted {
let prov = Provenance::cli(
format!("-{}", name.chars().next().unwrap_or('?')),
acc.count.to_string(),
);
let value = ConfigValue::Integer(Sourced {
value: acc.count as i64,
span: None,
provenance: Some(prov),
});
self.result.insert(name.clone(), value);
}
}
fn emit_error(&mut self, message: String) {
self.emit_error_at(message, self.current_span());
}
fn emit_error_at(&mut self, message: String, span: facet_reflect::Span) {
self.diagnostics.push(Diagnostic {
message,
label: None,
path: None,
span: Some(crate::span::Span::new(
span.offset as usize,
span.len as usize,
)),
severity: Severity::Error,
});
}
fn into_output(mut self) -> LayerOutput {
let mut unused_keys = Vec::new();
let mut diagnostics = self.diagnostics;
if let Some(builder) = self.config_builder
&& let Some(config_schema) = self.schema.config()
{
let config_field_name = config_schema.field_name();
let builder_output = builder.into_output(None);
if let Some(config_value) = builder_output.value
&& let Some(name) = config_field_name
{
self.result.insert(name.to_string(), config_value);
}
unused_keys.extend(builder_output.unused_keys);
diagnostics.extend(builder_output.diagnostics);
}
let value = if self.result.is_empty() {
Some(ConfigValue::Object(Sourced {
value: IndexMap::default(),
span: None,
provenance: None,
}))
} else {
Some(ConfigValue::Object(Sourced {
value: self.result,
span: None,
provenance: None,
}))
};
LayerOutput {
value,
unused_keys,
diagnostics,
source_text: None,
config_file_path: self.config_file_path,
help_list_mode: self.help_list_mode,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate as args;
use facet::Facet;
use facet_testhelpers::test;
mod cv {
use super::*;
pub fn bool(value: bool, arg: &str) -> ConfigValue {
ConfigValue::Bool(Sourced {
value,
span: None,
provenance: Some(Provenance::cli(arg, value.to_string())),
})
}
pub fn int(value: i64, arg: &str) -> ConfigValue {
ConfigValue::Integer(Sourced {
value,
span: None,
provenance: Some(Provenance::cli(arg, value.to_string())),
})
}
pub fn string(value: impl Into<String>, arg: &str) -> ConfigValue {
let s = value.into();
ConfigValue::String(Sourced {
value: s.clone(),
span: None,
provenance: Some(Provenance::cli(arg, s)),
})
}
pub fn object(
fields: impl IntoIterator<Item = (&'static str, ConfigValue)>,
) -> ConfigValue {
let map: IndexMap<String, ConfigValue, std::hash::RandomState> = fields
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
ConfigValue::Object(Sourced {
value: map,
span: None,
provenance: Some(Provenance::Cli {
arg: String::new(),
value: String::new(),
}),
})
}
pub fn enumv(
variant: &str,
fields: impl IntoIterator<Item = (&'static str, ConfigValue)>,
) -> ConfigValue {
use crate::config_value::EnumValue;
let fields_map: IndexMap<String, ConfigValue, std::hash::RandomState> = fields
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
ConfigValue::Enum(Sourced {
value: EnumValue {
variant: variant.to_string(),
fields: fields_map,
},
span: None,
provenance: Some(Provenance::Cli {
arg: variant.to_string(),
value: String::new(),
}),
})
}
}
fn cli_config(args: &[&str]) -> CliConfig {
CliConfigBuilder::new()
.args(args.iter().map(|s| s.to_string()))
.build()
}
fn assert_parses_to<T: Facet<'static>>(args: &[&str], expected: ConfigValue) {
let schema = Schema::from_shape(T::SHAPE).expect("valid schema");
tracing::debug!(args = ?schema.args().args().keys().collect::<Vec<_>>(), "Schema args");
let config = cli_config(args);
let output = parse_cli(&schema, &config);
assert!(
output.diagnostics.is_empty(),
"unexpected diagnostics: {:?}",
output.diagnostics
);
let value = output.value.expect("expected a value");
assert_config_value_eq(&value, &expected);
}
fn assert_diagnostic_contains<T: Facet<'static>>(args: &[&str], expected_msg: &str) {
let schema = Schema::from_shape(T::SHAPE).expect("valid schema");
let config = cli_config(args);
let output = parse_cli(&schema, &config);
assert!(
output
.diagnostics
.iter()
.any(|d| d.message.contains(expected_msg)),
"expected diagnostic containing {:?}, got: {:?}",
expected_msg,
output.diagnostics
);
}
fn assert_config_value_eq(actual: &ConfigValue, expected: &ConfigValue) {
match (actual, expected) {
(ConfigValue::Bool(a), ConfigValue::Bool(e)) => {
assert_eq!(a.value, e.value, "bool mismatch");
}
(ConfigValue::Integer(a), ConfigValue::Integer(e)) => {
assert_eq!(a.value, e.value, "integer mismatch");
}
(ConfigValue::String(a), ConfigValue::String(e)) => {
assert_eq!(a.value, e.value, "string mismatch");
}
(ConfigValue::Object(a), ConfigValue::Object(e)) => {
let mut actual_keys: Vec<_> = a.value.keys().collect();
let mut expected_keys: Vec<_> = e.value.keys().collect();
actual_keys.sort();
expected_keys.sort();
assert_eq!(actual_keys, expected_keys, "object keys mismatch");
for (key, expected_val) in &e.value {
let actual_val = a
.value
.get(key)
.unwrap_or_else(|| panic!("missing key: {}", key));
assert_config_value_eq(actual_val, expected_val);
}
}
(ConfigValue::Array(a), ConfigValue::Array(e)) => {
assert_eq!(a.value.len(), e.value.len(), "array length mismatch");
for (av, ev) in a.value.iter().zip(e.value.iter()) {
assert_config_value_eq(av, ev);
}
}
(ConfigValue::Enum(a), ConfigValue::Enum(e)) => {
assert_eq!(a.value.variant, e.value.variant, "enum variant mismatch");
let mut actual_keys: Vec<_> = a.value.fields.keys().collect();
let mut expected_keys: Vec<_> = e.value.fields.keys().collect();
actual_keys.sort();
expected_keys.sort();
assert_eq!(actual_keys, expected_keys, "enum fields mismatch");
for (key, expected_val) in &e.value.fields {
let actual_val = a
.value
.fields
.get(key)
.unwrap_or_else(|| panic!("missing enum field: {}", key));
assert_config_value_eq(actual_val, expected_val);
}
}
(ConfigValue::Null(_), ConfigValue::Null(_)) => {}
_ => panic!(
"ConfigValue variant mismatch: {:?} vs {:?}",
core::mem::discriminant(actual),
core::mem::discriminant(expected)
),
}
}
#[derive(Facet)]
struct SimpleArgs {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::named, args::short = 'p')]
port: Option<u16>,
}
#[derive(Facet)]
struct ArgsWithPositional {
#[facet(args::positional)]
input: String,
#[facet(args::positional)]
output: Option<String>,
}
#[derive(Facet)]
struct ArgsWithConfig {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::config)]
config: ServerConfig,
}
#[derive(Facet)]
struct ServerConfig {
port: u16,
host: String,
}
#[derive(Facet)]
struct ArgsWithSubcommand {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::subcommand)]
command: Command,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum Command {
Build(BuildArgs),
Test(TestArgs),
}
#[derive(Facet)]
struct BuildArgs {
#[facet(args::named, args::short = 'r')]
release: bool,
}
#[derive(Facet)]
struct TestArgs {
#[facet(args::named)]
filter: Option<String>,
}
#[derive(Facet)]
struct CountedArgs {
#[facet(args::named, args::short = 'v', args::counted)]
verbose: u8,
}
#[derive(Facet)]
struct MultiNamedArgs {
#[facet(args::named)]
items: Vec<String>,
}
#[test]
fn test_empty_args() {
assert_parses_to::<SimpleArgs>(&[], cv::object([]));
}
#[test]
fn test_long_bool_flag() {
assert_parses_to::<SimpleArgs>(
&["--verbose"],
cv::object([("verbose", cv::bool(true, "--verbose"))]),
);
}
#[test]
fn test_short_bool_flag() {
assert_parses_to::<SimpleArgs>(&["-v"], cv::object([("verbose", cv::bool(true, "-v"))]));
}
#[test]
fn test_long_flag_with_value() {
assert_parses_to::<SimpleArgs>(
&["--port", "8080"],
cv::object([("port", cv::string("8080", "--port"))]),
);
}
#[test]
fn test_long_flag_with_equals() {
assert_parses_to::<SimpleArgs>(
&["--port=8080"],
cv::object([("port", cv::string("8080", "--port"))]),
);
}
#[test]
fn test_short_flag_with_value() {
assert_parses_to::<SimpleArgs>(
&["-p", "8080"],
cv::object([("port", cv::string("8080", "-p"))]),
);
}
#[test]
fn test_short_flag_attached_value() {
assert_parses_to::<SimpleArgs>(
&["-p8080"],
cv::object([("port", cv::string("8080", "-p"))]),
);
}
#[test]
fn test_multiple_flags() {
assert_parses_to::<SimpleArgs>(
&["--verbose", "--port", "8080"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
("port", cv::string("8080", "--port")),
]),
);
}
#[test]
fn test_multiple_named_array_container_keeps_metadata() {
let schema = Schema::from_shape(MultiNamedArgs::SHAPE).expect("valid schema");
let output = parse_cli(&schema, &cli_config(&["--items", "one", "--items", "two"]));
assert!(output.diagnostics.is_empty(), "{:?}", output.diagnostics);
let root = output.value.expect("expected value");
let ConfigValue::Object(obj) = root else {
panic!("expected object root");
};
let Some(ConfigValue::Array(arr)) = obj.value.get("items") else {
panic!("expected items array");
};
assert_eq!(arr.value.len(), 2);
assert!(arr.span.is_some(), "array wrapper span should be set");
assert!(
arr.provenance.is_some(),
"array wrapper provenance should be set"
);
match arr.provenance.as_ref() {
Some(Provenance::Cli { arg, .. }) => assert_eq!(arg, "--items"),
other => panic!("expected CLI provenance, got: {other:?}"),
}
}
#[test]
fn test_single_positional() {
assert_parses_to::<ArgsWithPositional>(
&["input.txt"],
cv::object([("input", cv::string("input.txt", "input.txt"))]),
);
}
#[test]
fn test_multiple_positionals() {
assert_parses_to::<ArgsWithPositional>(
&["input.txt", "output.txt"],
cv::object([
("input", cv::string("input.txt", "input.txt")),
("output", cv::string("output.txt", "output.txt")),
]),
);
}
#[test]
fn test_positional_after_double_dash() {
assert_parses_to::<ArgsWithPositional>(
&["--", "--input.txt"],
cv::object([("input", cv::string("--input.txt", "--input.txt"))]),
);
}
#[test]
fn test_config_dotted_path() {
assert_parses_to::<ArgsWithConfig>(
&["--config.port", "8080"],
cv::object([(
"config",
cv::object([("port", cv::string("8080", "--config.port"))]),
)]),
);
}
#[test]
fn test_config_nested_dotted_path() {
assert_parses_to::<ArgsWithConfig>(
&["--config.host", "localhost"],
cv::object([(
"config",
cv::object([("host", cv::string("localhost", "--config.host"))]),
)]),
);
}
#[test]
fn test_args_and_config_together() {
assert_parses_to::<ArgsWithConfig>(
&["--verbose", "--config.port", "8080"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
(
"config",
cv::object([("port", cv::string("8080", "--config.port"))]),
),
]),
);
}
#[derive(Facet)]
struct CommonConfigCli {
log_level: String,
debug: bool,
}
#[derive(Facet)]
struct ServerConfigWithFlattenCli {
port: u16,
#[facet(flatten)]
common: CommonConfigCli,
}
#[derive(Facet)]
struct ArgsWithFlattenedConfigCli {
#[facet(args::named)]
verbose: bool,
#[facet(args::config)]
config: ServerConfigWithFlattenCli,
}
#[test]
fn test_config_override_with_flattened_struct() {
assert_parses_to::<ArgsWithFlattenedConfigCli>(
&["--config.log_level", "debug"],
cv::object([(
"config",
cv::object([("log_level", cv::string("debug", "--config.log_level"))]),
)]),
);
}
#[test]
fn test_config_override_flattened_and_regular_fields() {
assert_parses_to::<ArgsWithFlattenedConfigCli>(
&["--config.port", "8080", "--config.debug", "true"],
cv::object([(
"config",
cv::object([
("port", cv::string("8080", "--config.port")),
("debug", cv::string("true", "--config.debug")),
]),
)]),
);
}
#[test]
fn test_subcommand_basic() {
assert_parses_to::<ArgsWithSubcommand>(
&["build"],
cv::object([("command", cv::enumv("Build", []))]),
);
}
#[test]
fn test_subcommand_with_args() {
assert_parses_to::<ArgsWithSubcommand>(
&["build", "--release"],
cv::object([(
"command",
cv::enumv("Build", [("release", cv::bool(true, "--release"))]),
)]),
);
}
#[test]
fn test_global_flag_before_subcommand() {
assert_parses_to::<ArgsWithSubcommand>(
&["--verbose", "build", "--release"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
(
"command",
cv::enumv("Build", [("release", cv::bool(true, "--release"))]),
),
]),
);
}
#[test]
fn test_counted_single() {
assert_parses_to::<CountedArgs>(&["-v"], cv::object([("verbose", cv::int(1, "-v"))]));
}
#[test]
fn test_counted_chained() {
assert_parses_to::<CountedArgs>(&["-vvv"], cv::object([("verbose", cv::int(3, "-vvv"))]));
}
#[test]
fn test_counted_repeated() {
assert_parses_to::<CountedArgs>(
&["-v", "-v", "-v"],
cv::object([("verbose", cv::int(3, "-v"))]),
);
}
#[test]
fn test_unknown_long_flag() {
assert_diagnostic_contains::<SimpleArgs>(&["--unknown"], "unknown");
}
#[test]
fn test_unknown_short_flag() {
assert_diagnostic_contains::<SimpleArgs>(&["-x"], "unknown");
}
#[test]
fn test_missing_value_for_flag() {
assert_diagnostic_contains::<SimpleArgs>(&["--port"], "requires a value");
}
#[test]
fn test_triple_dash_flag() {
assert_diagnostic_contains::<SimpleArgs>(&["---verbose"], "unknown");
}
#[derive(Facet)]
struct CommonArgs {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::named, args::short = 'q')]
quiet: bool,
}
#[derive(Facet)]
struct ArgsWithFlatten {
#[facet(args::positional)]
input: String,
#[facet(flatten)]
common: CommonArgs,
}
#[test]
fn test_flatten_parses_flags_flat() {
let expected = cv::object([
("input", cv::string("file.txt", "file.txt")),
("verbose", cv::bool(true, "-v")),
]);
assert_parses_to::<ArgsWithFlatten>(&["-v", "file.txt"], expected);
}
#[test]
fn test_flatten_multiple_flags() {
let expected = cv::object([
("input", cv::string("test.txt", "test.txt")),
("verbose", cv::bool(true, "-v")),
("quiet", cv::bool(true, "-q")),
]);
assert_parses_to::<ArgsWithFlatten>(&["-v", "-q", "test.txt"], expected);
}
#[test]
fn test_flatten_long_flags() {
let expected = cv::object([
("input", cv::string("data.txt", "data.txt")),
("verbose", cv::bool(true, "--verbose")),
]);
assert_parses_to::<ArgsWithFlatten>(&["--verbose", "data.txt"], expected);
}
#[derive(Facet)]
struct NestedCommon {
#[facet(args::named)]
debug: bool,
}
#[derive(Facet)]
struct MiddleLayer {
#[facet(flatten)]
nested: NestedCommon,
#[facet(args::named)]
log_level: Option<String>,
}
#[derive(Facet)]
struct DeepFlatten {
#[facet(args::positional)]
path: String,
#[facet(flatten)]
options: MiddleLayer,
}
#[test]
fn test_deeply_nested_flatten() {
let expected = cv::object([
("path", cv::string("file.txt", "file.txt")),
("debug", cv::bool(true, "--debug")),
]);
assert_parses_to::<DeepFlatten>(&["--debug", "file.txt"], expected);
}
#[derive(Facet)]
struct SimpleFlags {
#[facet(args::named)]
verbose: bool,
#[facet(args::named)]
quiet: bool,
}
#[test]
fn test_cv_simple_flags_flat() {
assert_parses_to::<SimpleFlags>(
&["--verbose"],
cv::object([("verbose", cv::bool(true, "--verbose"))]),
);
}
#[test]
fn test_cv_multiple_simple_flags_flat() {
assert_parses_to::<SimpleFlags>(
&["--verbose", "--quiet"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
("quiet", cv::bool(true, "--quiet")),
]),
);
}
#[derive(Facet)]
struct InnerOpts {
#[facet(args::named)]
debug: bool,
#[facet(args::named)]
trace: bool,
}
#[derive(Facet)]
struct OuterWithFlatten {
#[facet(args::named)]
verbose: bool,
#[facet(flatten)]
inner: InnerOpts,
}
#[test]
fn test_cv_flattened_fields_at_current_level() {
assert_parses_to::<OuterWithFlatten>(
&["--debug"],
cv::object([("debug", cv::bool(true, "--debug"))]),
);
}
#[test]
fn test_cv_flattened_mixed_with_outer() {
assert_parses_to::<OuterWithFlatten>(
&["--verbose", "--trace"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
("trace", cv::bool(true, "--trace")),
]),
);
}
#[derive(Facet)]
struct DatabaseConfig {
host: String,
port: u16,
}
#[derive(Facet)]
struct AppWithConfig {
#[facet(args::named)]
verbose: bool,
#[facet(args::config)]
db: Option<DatabaseConfig>,
}
#[test]
fn test_cv_config_override_nested() {
assert_parses_to::<AppWithConfig>(
&["--db.host", "localhost"],
cv::object([(
"db",
cv::object([("host", cv::string("localhost", "--db.host"))]),
)]),
);
}
#[test]
fn test_cv_config_override_deeply_nested() {
assert_parses_to::<AppWithConfig>(
&["--db.host", "localhost", "--db.port", "5432"],
cv::object([(
"db",
cv::object([
("host", cv::string("localhost", "--db.host")),
("port", cv::string("5432", "--db.port")),
]),
)]),
);
}
#[derive(Facet)]
struct CvBuildOpts {
#[facet(args::named)]
release: bool,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum CvCommand {
Build {
#[facet(flatten)]
opts: CvBuildOpts,
},
Test {
#[facet(args::named)]
coverage: bool,
},
}
#[derive(Facet)]
struct AppWithSubcommand {
#[facet(args::named)]
verbose: bool,
#[facet(args::subcommand)]
cmd: CvCommand,
}
#[test]
fn test_cv_subcommand_enum_structure() {
assert_parses_to::<AppWithSubcommand>(
&["build", "--release"],
cv::object([(
"cmd",
cv::enumv("Build", [("release", cv::bool(true, "--release"))]),
)]),
);
}
#[test]
fn test_cv_subcommand_with_outer_flag() {
assert_parses_to::<AppWithSubcommand>(
&["--verbose", "test", "--coverage"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
(
"cmd",
cv::enumv("Test", [("coverage", cv::bool(true, "--coverage"))]),
),
]),
);
}
#[derive(Facet)]
struct Level3 {
#[facet(args::named)]
deep_flag: bool,
}
#[derive(Facet)]
struct Level2 {
#[facet(flatten)]
level3: Level3,
}
#[derive(Facet)]
struct Level1 {
#[facet(flatten)]
level2: Level2,
}
#[test]
fn test_cv_deeply_nested_flatten_still_flat() {
assert_parses_to::<Level1>(
&["--deep-flag"],
cv::object([("deep_flag", cv::bool(true, "--deep-flag"))]),
);
}
#[derive(Facet)]
struct RenamedFields {
#[facet(args::named, rename = "output-dir")]
output: String,
}
#[test]
fn test_cv_renamed_field_uses_effective_name() {
assert_parses_to::<RenamedFields>(
&["--output-dir", "/tmp"],
cv::object([("output-dir", cv::string("/tmp", "--output-dir"))]),
);
}
#[derive(Facet)]
struct CvLoggingConfig {
level: String,
}
#[derive(Facet)]
struct CvServerConfig {
port: u16,
#[facet(flatten)]
logging: CvLoggingConfig,
}
#[derive(Facet)]
struct AppWithFlattenedConfig {
#[facet(args::config)]
server: Option<CvServerConfig>,
}
#[test]
fn test_cv_config_with_flattened_inner() {
assert_parses_to::<AppWithFlattenedConfig>(
&["--server.level", "debug"],
cv::object([(
"server",
cv::object([("level", cv::string("debug", "--server.level"))]),
)]),
);
}
#[test]
fn test_cv_config_non_flattened_field() {
assert_parses_to::<AppWithFlattenedConfig>(
&["--server.port", "8080"],
cv::object([(
"server",
cv::object([("port", cv::string("8080", "--server.port"))]),
)]),
);
}
#[derive(Facet)]
struct GlobalOpts {
#[facet(args::named, args::short = 'v')]
verbose: bool,
}
#[derive(Facet)]
struct CvComplexApp {
#[facet(flatten)]
global: GlobalOpts,
#[facet(args::config)]
config: Option<CvServerConfig>,
#[facet(args::subcommand)]
command: Option<CvCommand>,
}
#[test]
fn test_cv_complex_mix() {
assert_parses_to::<CvComplexApp>(
&["-v", "--config.port", "9000", "build", "--release"],
cv::object([
("verbose", cv::bool(true, "-v")),
(
"config",
cv::object([("port", cv::string("9000", "--config.port"))]),
),
(
"command",
cv::enumv("Build", [("release", cv::bool(true, "--release"))]),
),
]),
);
}
#[derive(Facet)]
struct SubBuildConfig {
#[facet(args::named)]
jobs: Option<u32>,
#[facet(args::named)]
target: Option<String>,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum SubCommand1 {
Build {
#[facet(flatten)]
config: SubBuildConfig,
},
}
#[derive(Facet)]
struct AppWithSubcommandFlattenedField {
#[facet(args::subcommand)]
cmd: SubCommand1,
}
#[test]
fn test_cv_subcommand_flattened_struct_field() {
assert_parses_to::<AppWithSubcommandFlattenedField>(
&["build", "--jobs", "4"],
cv::object([(
"cmd",
cv::enumv("Build", [("jobs", cv::string("4", "--jobs"))]),
)]),
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum DbSubCommand {
Create {
#[facet(args::positional)]
name: String,
},
Drop {
#[facet(args::positional)]
name: String,
#[facet(args::named)]
force: bool,
},
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum TopCommand {
Db {
#[facet(args::subcommand)]
action: DbSubCommand,
},
Version,
}
#[derive(Facet)]
struct AppWithNestedSubcommands {
#[facet(args::subcommand)]
cmd: TopCommand,
}
#[test]
fn test_cv_nested_subcommands() {
assert_parses_to::<AppWithNestedSubcommands>(
&["db", "create", "mydb"],
cv::object([(
"cmd",
cv::enumv(
"Db",
[(
"action",
cv::enumv("Create", [("name", cv::string("mydb", "mydb"))]),
)],
),
)]),
);
}
#[test]
fn test_cv_nested_subcommands_with_flags() {
assert_parses_to::<AppWithNestedSubcommands>(
&["db", "drop", "mydb", "--force"],
cv::object([(
"cmd",
cv::enumv(
"Db",
[(
"action",
cv::enumv(
"Drop",
[
("name", cv::string("mydb", "mydb")),
("force", cv::bool(true, "--force")),
],
),
)],
),
)]),
);
}
#[derive(Facet)]
struct InstallOpts {
#[facet(args::named)]
global: bool,
#[facet(args::positional)]
package: String,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum PkgCommand {
Install(#[facet(flatten)] InstallOpts),
Uninstall {
#[facet(args::positional)]
package: String,
},
}
#[derive(Facet)]
struct AppWithTupleVariant {
#[facet(args::subcommand)]
cmd: PkgCommand,
}
#[test]
fn test_cv_tuple_variant_flattened() {
assert_parses_to::<AppWithTupleVariant>(
&["install", "--global", "lodash"],
cv::object([(
"cmd",
cv::enumv(
"Install",
[
("global", cv::bool(true, "--global")),
("package", cv::string("lodash", "lodash")),
],
),
)]),
);
}
#[test]
fn test_cv_struct_variant_after_tuple() {
assert_parses_to::<AppWithTupleVariant>(
&["uninstall", "lodash"],
cv::object([(
"cmd",
cv::enumv("Uninstall", [("package", cv::string("lodash", "lodash"))]),
)]),
);
}
#[derive(Facet)]
struct DeepOpts {
#[facet(args::named)]
deep: bool,
}
#[derive(Facet)]
struct MiddleOpts {
#[facet(flatten)]
deep_opts: DeepOpts,
#[facet(args::named)]
middle: bool,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum DeepFlattenCmd {
Run {
#[facet(flatten)]
opts: MiddleOpts,
#[facet(args::positional)]
target: String,
},
}
#[derive(Facet)]
struct AppWithDeepFlattenSubcmd {
#[facet(args::subcommand)]
cmd: DeepFlattenCmd,
}
#[test]
fn test_cv_subcommand_deep_flatten() {
assert_parses_to::<AppWithDeepFlattenSubcmd>(
&["run", "--deep", "--middle", "mytarget"],
cv::object([(
"cmd",
cv::enumv(
"Run",
[
("deep", cv::bool(true, "--deep")),
("middle", cv::bool(true, "--middle")),
("target", cv::string("mytarget", "mytarget")),
],
),
)]),
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum SimpleCmd {
Start,
Stop,
Status {
#[facet(args::named)]
verbose: bool,
},
}
#[derive(Facet)]
struct AppWithUnitVariant {
#[facet(args::subcommand)]
cmd: SimpleCmd,
}
#[test]
fn test_cv_unit_variant_subcommand() {
assert_parses_to::<AppWithUnitVariant>(
&["start"],
cv::object([("cmd", cv::enumv("Start", []))]),
);
}
#[test]
fn test_cv_unit_variant_then_struct_variant() {
assert_parses_to::<AppWithUnitVariant>(
&["status", "--verbose"],
cv::object([(
"cmd",
cv::enumv("Status", [("verbose", cv::bool(true, "--verbose"))]),
)]),
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum RenamedCmd {
#[facet(rename = "ls")]
List {
#[facet(args::named)]
all: bool,
},
#[facet(rename = "rm")]
Remove {
#[facet(args::positional)]
path: String,
},
}
#[derive(Facet)]
struct AppWithRenamedVariants {
#[facet(args::subcommand)]
cmd: RenamedCmd,
}
#[test]
fn test_cv_renamed_subcommand_variant() {
assert_parses_to::<AppWithRenamedVariants>(
&["ls", "--all"],
cv::object([("cmd", cv::enumv("ls", [("all", cv::bool(true, "--all"))]))]),
);
}
#[test]
fn test_cv_another_renamed_variant() {
assert_parses_to::<AppWithRenamedVariants>(
&["rm", "/tmp/foo"],
cv::object([(
"cmd",
cv::enumv("rm", [("path", cv::string("/tmp/foo", "/tmp/foo"))]),
)]),
);
}
#[derive(Facet)]
struct GlobalFlags {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::named, args::short = 'q')]
quiet: bool,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum ActionCmd {
Run {
#[facet(args::positional)]
script: String,
},
}
#[derive(Facet)]
struct AppWithGlobalFlags {
#[facet(flatten)]
globals: GlobalFlags,
#[facet(args::subcommand)]
action: ActionCmd,
}
#[test]
fn test_cv_global_flags_before_subcommand() {
assert_parses_to::<AppWithGlobalFlags>(
&["-v", "run", "script.sh"],
cv::object([
("verbose", cv::bool(true, "-v")),
(
"action",
cv::enumv("Run", [("script", cv::string("script.sh", "script.sh"))]),
),
]),
);
}
#[test]
fn test_cv_multiple_global_flags() {
assert_parses_to::<AppWithGlobalFlags>(
&["-v", "-q", "run", "test.sh"],
cv::object([
("verbose", cv::bool(true, "-v")),
("quiet", cv::bool(true, "-q")),
(
"action",
cv::enumv("Run", [("script", cv::string("test.sh", "test.sh"))]),
),
]),
);
}
#[derive(Facet)]
#[repr(u8)]
enum OptionalCmd {
Init,
Build,
}
#[derive(Facet)]
struct AppWithOptionalSubcommand {
#[facet(args::named)]
version: bool,
#[facet(args::subcommand)]
cmd: Option<OptionalCmd>,
}
#[test]
fn test_cv_optional_subcommand_present() {
assert_parses_to::<AppWithOptionalSubcommand>(
&["init"],
cv::object([("cmd", cv::enumv("Init", []))]),
);
}
#[test]
fn test_cv_optional_subcommand_absent() {
assert_parses_to::<AppWithOptionalSubcommand>(
&["--version"],
cv::object([("version", cv::bool(true, "--version"))]),
);
}
#[derive(Facet)]
struct ParentWithGlobalFlag {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::subcommand)]
command: ChildCommand,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum ChildCommand {
Build {
#[facet(args::named)]
release: bool,
},
Test {
#[facet(args::positional)]
filter: Option<String>,
},
}
#[test]
fn test_adoption_flag_after_subcommand_bubbles_up() {
assert_parses_to::<ParentWithGlobalFlag>(
&["build", "--verbose"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
("command", cv::enumv("Build", [])),
]),
);
}
#[test]
fn test_adoption_short_flag_after_subcommand_bubbles_up() {
assert_parses_to::<ParentWithGlobalFlag>(
&["build", "-v"],
cv::object([
("verbose", cv::bool(true, "-v")),
("command", cv::enumv("Build", [])),
]),
);
}
#[test]
fn test_adoption_mixed_flags_some_bubble() {
assert_parses_to::<ParentWithGlobalFlag>(
&["build", "--release", "--verbose"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
(
"command",
cv::enumv("Build", [("release", cv::bool(true, "--release"))]),
),
]),
);
}
#[test]
fn test_adoption_flag_before_subcommand_stays_at_parent() {
assert_parses_to::<ParentWithGlobalFlag>(
&["--verbose", "build"],
cv::object([
("verbose", cv::bool(true, "--verbose")),
("command", cv::enumv("Build", [])),
]),
);
}
#[derive(Facet)]
struct GrandparentWithFlag {
#[facet(args::named)]
debug: bool,
#[facet(args::subcommand)]
command: ParentCommand,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum ParentCommand {
Repo {
#[facet(args::named)]
quiet: bool,
#[facet(args::subcommand)]
action: RepoAction,
},
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum RepoAction {
Clone {
#[facet(args::positional)]
url: String,
},
Push {
#[facet(args::named)]
force: bool,
},
}
#[test]
fn test_adoption_nested_bubbles_to_immediate_parent() {
assert_parses_to::<GrandparentWithFlag>(
&["repo", "clone", "https://example.com", "--quiet"],
cv::object([(
"command",
cv::enumv(
"Repo",
[
("quiet", cv::bool(true, "--quiet")),
(
"action",
cv::enumv(
"Clone",
[(
"url",
cv::string("https://example.com", "https://example.com"),
)],
),
),
],
),
)]),
);
}
#[test]
fn test_adoption_nested_bubbles_to_grandparent() {
assert_parses_to::<GrandparentWithFlag>(
&["repo", "clone", "https://example.com", "--debug"],
cv::object([
("debug", cv::bool(true, "--debug")),
(
"command",
cv::enumv(
"Repo",
[(
"action",
cv::enumv(
"Clone",
[(
"url",
cv::string("https://example.com", "https://example.com"),
)],
),
)],
),
),
]),
);
}
#[test]
fn test_adoption_nested_mixed_levels() {
assert_parses_to::<GrandparentWithFlag>(
&["repo", "push", "--force", "--quiet", "--debug"],
cv::object([
("debug", cv::bool(true, "--debug")),
(
"command",
cv::enumv(
"Repo",
[
("quiet", cv::bool(true, "--quiet")),
(
"action",
cv::enumv("Push", [("force", cv::bool(true, "--force"))]),
),
],
),
),
]),
);
}
#[derive(Facet)]
struct ParentWithShadowedFlag {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::subcommand)]
command: ChildWithSameFlag,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum ChildWithSameFlag {
Run {
#[facet(args::named, args::short = 'v')]
verbose: bool,
#[facet(args::positional)]
script: String,
},
}
#[test]
fn test_adoption_local_flag_shadows_parent() {
assert_parses_to::<ParentWithShadowedFlag>(
&["run", "script.sh", "--verbose"],
cv::object([(
"command",
cv::enumv(
"Run",
[
("verbose", cv::bool(true, "--verbose")),
("script", cv::string("script.sh", "script.sh")),
],
),
)]),
);
}
#[test]
fn test_adoption_short_flag_shadows_parent() {
assert_parses_to::<ParentWithShadowedFlag>(
&["run", "script.sh", "-v"],
cv::object([(
"command",
cv::enumv(
"Run",
[
("verbose", cv::bool(true, "-v")),
("script", cv::string("script.sh", "script.sh")),
],
),
)]),
);
}
#[derive(Facet)]
struct AppWithBuiltins {
#[facet(flatten)]
builtins: crate::FigueBuiltins,
#[facet(args::subcommand)]
command: AppCommand,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum AppCommand {
Install {
#[facet(args::positional)]
package: String,
},
Uninstall {
#[facet(args::positional)]
package: String,
#[facet(args::named)]
force: bool,
},
}
#[derive(Facet)]
struct AppWithBuiltinsNestedSubcommands {
#[facet(flatten)]
builtins: crate::FigueBuiltins,
#[facet(args::subcommand)]
command: BuiltinsRootCommand,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum BuiltinsRootCommand {
Home(BuiltinsHomeCommandArgs),
}
#[derive(Facet)]
struct BuiltinsHomeCommandArgs {
#[facet(args::subcommand)]
action: BuiltinsHomeAction,
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum BuiltinsHomeAction {
Open,
}
#[test]
fn test_help_word_bubbles_from_nested_leaf_subcommand() {
assert_parses_to::<AppWithBuiltinsNestedSubcommands>(
&["home", "open", "help"],
cv::object([
("help", cv::bool(true, "help")),
(
"command",
cv::enumv("Home", [("action", cv::enumv("Open", []))]),
),
]),
);
}
#[derive(Facet)]
#[repr(u8)]
#[allow(dead_code)]
enum AppCommandWithHelp {
Help,
Install {
#[facet(args::positional)]
package: String,
},
}
#[derive(Facet)]
struct AppWithBuiltinsAndHelpSubcommand {
#[facet(flatten)]
builtins: crate::FigueBuiltins,
#[facet(args::subcommand)]
command: AppCommandWithHelp,
}
#[test]
fn test_adoption_help_flag_bubbles_from_subcommand() {
assert_parses_to::<AppWithBuiltins>(
&["install", "foo", "--help"],
cv::object([
("help", cv::bool(true, "--help")),
(
"command",
cv::enumv("Install", [("package", cv::string("foo", "foo"))]),
),
]),
);
}
#[test]
fn test_help_word_acts_like_help_flag_when_no_help_subcommand() {
assert_parses_to::<AppWithBuiltins>(
&["help"],
cv::object([("help", cv::bool(true, "help"))]),
);
}
#[test]
fn test_help_list_word_acts_like_help_flag_when_no_help_subcommand() {
assert_parses_to::<AppWithBuiltins>(
&["help", "list"],
cv::object([("help", cv::bool(true, "help"))]),
);
}
#[test]
fn test_help_list_short_word_acts_like_help_flag_when_no_help_subcommand() {
assert_parses_to::<AppWithBuiltins>(
&["help", "list", "--short"],
cv::object([("help", cv::bool(true, "help"))]),
);
}
#[test]
fn test_help_word_prefers_explicit_help_subcommand() {
assert_parses_to::<AppWithBuiltinsAndHelpSubcommand>(
&["help"],
cv::object([("command", cv::enumv("Help", []))]),
);
}
#[test]
fn test_adoption_version_flag_bubbles_from_subcommand() {
assert_parses_to::<AppWithBuiltins>(
&["uninstall", "bar", "--version"],
cv::object([
("version", cv::bool(true, "--version")),
(
"command",
cv::enumv("Uninstall", [("package", cv::string("bar", "bar"))]),
),
]),
);
}
#[test]
fn test_adoption_help_short_flag_bubbles() {
assert_parses_to::<AppWithBuiltins>(
&["install", "foo", "-h"],
cv::object([
("help", cv::bool(true, "-h")),
(
"command",
cv::enumv("Install", [("package", cv::string("foo", "foo"))]),
),
]),
);
}
}