use std::ffi::OsString;
use clap::error::ErrorKind;
use clap::parser::ValueSource;
use clap::{Arg, ArgAction, ArgMatches, Command, Id};
pub trait EnvPrefix {
#[must_use]
fn env_prefix(self, prefix: &str) -> Self;
}
impl EnvPrefix for Command {
fn env_prefix(self, prefix: &str) -> Self {
self.mut_args(|arg| bind(arg, prefix))
.mut_subcommands(|subcommand| subcommand.env_prefix(prefix))
}
}
#[must_use]
fn env_name(prefix: &str, long: &str) -> String {
format!("{prefix}_{}", long.to_ascii_uppercase().replace('-', "_"))
}
fn bind(arg: Arg, prefix: &str) -> Arg {
let name = match arg.get_long() {
Some(long)
if arg.get_env().is_none()
&& !arg.is_hide_set()
&& !long.starts_with("no-")
&& !matches!(
arg.get_action(),
ArgAction::Help
| ArgAction::HelpShort
| ArgAction::HelpLong
| ArgAction::Version
) =>
{
env_name(prefix, long)
}
_ => return arg,
};
arg.env(name)
}
pub fn env_under_flags(command: Command, raw: &[OsString]) -> Result<Command, clap::Error> {
let mut probe = command.clone().ignore_errors(true);
let Ok(matches) = probe.try_get_matches_from_mut(raw) else {
return Ok(command);
};
let levels = levels(&probe, &matches, &command);
let placed = placed(&levels);
let mut unset = Vec::new();
let mut kept = Vec::<&Placed<'_>>::new();
for option in placed
.iter()
.filter(|option| option.arg.get_env().is_some())
{
let overruled = !option.is_set()
|| option.given
|| placed
.iter()
.any(|given| given.given && (option.negates(given) || option.conflicts(given)));
if overruled {
unset.push((option.depth, option.arg.get_id().clone()));
} else if let Some(other) = kept.iter().find(|other| option.conflicts(other)) {
let mut command = levels
.last()
.map_or_else(|| probe.clone(), |(level, _)| (*level).clone());
return Err(clap::Error::raw(
ErrorKind::ArgumentConflict,
format!(
"the variables `{}` and `{}` cannot both be set\n",
other.env_name(),
option.env_name()
),
)
.format(&mut command));
} else {
kept.push(option);
}
}
let path = levels
.iter()
.skip(1)
.map(|(level, _)| level.get_name())
.collect::<Vec<_>>();
Ok(unbind(command, &path, 0, &unset))
}
fn levels<'a>(
mut probe: &'a Command,
mut matches: &'a ArgMatches,
mut declared: &Command,
) -> Vec<(&'a Command, &'a ArgMatches)> {
let mut levels = vec![(probe, matches)];
while let Some((name, sub)) = matches.subcommand() {
let (Some(next), Some(next_declared)) =
(probe.find_subcommand(name), declared.find_subcommand(name))
else {
break;
};
probe = next;
matches = sub;
declared = next_declared;
levels.push((probe, matches));
}
levels
}
struct Placed<'a> {
depth: usize,
arg: &'a Arg,
conflicts: Vec<&'a Id>,
given: bool,
}
fn placed<'a>(levels: &[(&'a Command, &'a ArgMatches)]) -> Vec<Placed<'a>> {
let mut options = Vec::new();
for (depth, (level, matches)) in levels.iter().enumerate() {
let parent = depth.checked_sub(1).and_then(|up| levels.get(up));
for arg in level.get_arguments() {
let copied = arg.is_global_set()
&& parent.is_some_and(|(up, _)| {
up.get_arguments()
.any(|other| other.get_id() == arg.get_id())
});
if copied {
continue;
}
let mut conflicts = level
.get_arg_conflicts_with(&arg.clone().global(false))
.into_iter()
.map(Arg::get_id)
.collect::<Vec<_>>();
for group in level.get_groups() {
if group.get_args().any(|id| id == arg.get_id()) && !group.clone().is_multiple() {
conflicts.extend(group.get_args().filter(|id| *id != arg.get_id()));
}
}
options.push(Placed {
depth,
arg,
conflicts,
given: matches.value_source(arg.get_id().as_str())
== Some(ValueSource::CommandLine),
});
}
}
options
}
impl Placed<'_> {
fn env_name(&self) -> String {
self.arg
.get_env()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default()
}
fn is_set(&self) -> bool {
let Some(value) = self.arg.get_env().and_then(std::env::var_os) else {
return false;
};
let action = self.arg.get_action();
let default = if matches!(action, ArgAction::SetTrue) {
Some("false")
} else if matches!(action, ArgAction::SetFalse) {
Some("true")
} else {
None
};
!value.is_empty() && default.is_none_or(|default| value != default)
}
fn meets(&self, other: &Self) -> bool {
let upper = if self.depth <= other.depth {
self
} else {
other
};
self.depth == other.depth || upper.arg.is_global_set()
}
fn conflicts(&self, other: &Self) -> bool {
let exclusive = self.arg.is_exclusive_set() || other.arg.is_exclusive_set();
self.meets(other)
&& self.arg.get_id() != other.arg.get_id()
&& (exclusive
|| self.conflicts.contains(&other.arg.get_id())
|| other.conflicts.contains(&self.arg.get_id()))
}
fn negates(&self, other: &Self) -> bool {
let (Some(long), Some(other_long)) = (self.arg.get_long(), other.arg.get_long()) else {
return false;
};
self.meets(other)
&& (long.strip_prefix("no-") == Some(other_long)
|| other_long.strip_prefix("no-") == Some(long))
}
}
fn unbind(command: Command, path: &[&str], depth: usize, unset: &[(usize, Id)]) -> Command {
let command = command.mut_args(|arg| {
if unset
.iter()
.any(|(at, id)| *at == depth && id == arg.get_id())
{
arg.env(None::<&'static str>)
} else {
arg
}
});
match path.split_first() {
Some((name, rest)) => command.mut_subcommands(|sub| {
if sub.get_name() == *name {
unbind(sub, rest, depth + 1, unset)
} else {
sub
}
}),
None => command,
}
}
#[cfg(test)]
mod tests {
use clap::{Args, CommandFactory, Parser, Subcommand};
use super::{EnvPrefix, env_name, env_under_flags};
#[derive(Parser)]
#[command(env_prefix = "TOYCTL_UNIT")]
struct Toy {
#[arg(long, global = true)]
format: Option<String>,
#[arg(long)]
no_color: bool,
#[arg(long, hide = true)]
secret: bool,
#[arg(long, env = "OWN_NAME")]
token: Option<String>,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Prepare(PrepareArgs),
Group {
#[command(subcommand)]
command: Nested,
},
}
#[derive(Args)]
struct PrepareArgs {
#[arg(short = 'c', long)]
config: Option<String>,
path: Option<String>,
}
#[derive(Subcommand)]
enum Nested {
Show {
#[arg(long)]
dry_run: bool,
},
}
fn env_of(command: &clap::Command, id: &str) -> Option<String> {
command
.get_arguments()
.find(|arg| arg.get_id() == id)
.and_then(|arg| arg.get_env())
.map(|name| name.to_string_lossy().into_owned())
}
#[test]
fn a_long_name_becomes_the_prefixed_variable() {
assert_eq!(env_name("VERCTL", "dry-run"), "VERCTL_DRY_RUN");
assert_eq!(env_name("QCTL", "config"), "QCTL_CONFIG");
}
#[test]
fn every_eligible_long_option_is_bound_at_every_depth() {
let command = Toy::command();
assert_eq!(
env_of(&command, "format").as_deref(),
Some("TOYCTL_UNIT_FORMAT")
);
let prepare = command.find_subcommand("prepare").unwrap();
assert_eq!(
env_of(prepare, "config").as_deref(),
Some("TOYCTL_UNIT_CONFIG")
);
let show = command
.find_subcommand("group")
.and_then(|group| group.find_subcommand("show"))
.unwrap();
assert_eq!(
env_of(show, "dry_run").as_deref(),
Some("TOYCTL_UNIT_DRY_RUN")
);
}
#[test]
fn negations_hidden_positional_and_declared_options_keep_their_shape() {
let command = Toy::command();
assert_eq!(env_of(&command, "no_color"), None);
assert_eq!(env_of(&command, "secret"), None);
assert_eq!(env_of(&command, "token").as_deref(), Some("OWN_NAME"));
let prepare = command.find_subcommand("prepare").unwrap();
assert_eq!(env_of(prepare, "path"), None);
}
#[test]
fn a_global_option_that_conflicts_with_a_group_parses() {
let command = clap::Command::new("toy")
.arg(
clap::Arg::new("quiet")
.long("quiet")
.global(true)
.action(clap::ArgAction::SetTrue)
.conflicts_with("loud"),
)
.arg(
clap::Arg::new("verbose")
.long("verbose")
.action(clap::ArgAction::SetTrue),
)
.group(clap::ArgGroup::new("loud").arg("verbose"))
.env_prefix("TOYCTL_GROUP_UNIT");
let raw = ["toy", "--quiet"].map(std::ffi::OsString::from);
let matches = env_under_flags(command, &raw)
.unwrap()
.try_get_matches_from(&raw)
.unwrap();
assert!(matches.get_flag("quiet"));
}
#[test]
fn a_command_without_the_attribute_binds_nothing() {
let command = clap::Command::new("bare").arg(clap::Arg::new("format").long("format"));
assert_eq!(env_of(&command, "format"), None);
let bound = command.env_prefix("BARE");
assert_eq!(env_of(&bound, "format").as_deref(), Some("BARE_FORMAT"));
}
}