1use std::ffi::OsString;
4
5use clap::error::ErrorKind;
6use clap::parser::ValueSource;
7use clap::{Arg, ArgAction, ArgMatches, Command, Id};
8
9pub trait EnvPrefix {
26 #[must_use]
28 fn env_prefix(self, prefix: &str) -> Self;
29}
30
31impl EnvPrefix for Command {
32 fn env_prefix(self, prefix: &str) -> Self {
33 self.mut_args(|arg| bind(arg, prefix))
34 .mut_subcommands(|subcommand| subcommand.env_prefix(prefix))
35 }
36}
37
38#[must_use]
41fn env_name(prefix: &str, long: &str) -> String {
42 format!("{prefix}_{}", long.to_ascii_uppercase().replace('-', "_"))
43}
44
45fn bind(arg: Arg, prefix: &str) -> Arg {
46 let name = match arg.get_long() {
47 Some(long)
48 if arg.get_env().is_none()
49 && !arg.is_hide_set()
50 && !long.starts_with("no-")
51 && !matches!(
52 arg.get_action(),
53 ArgAction::Help
54 | ArgAction::HelpShort
55 | ArgAction::HelpLong
56 | ArgAction::Version
57 ) =>
58 {
59 env_name(prefix, long)
60 }
61 _ => return arg,
62 };
63 arg.env(name)
64}
65
66pub fn env_under_flags(command: Command, raw: &[OsString]) -> Result<Command, clap::Error> {
95 let mut probe = command.clone().ignore_errors(true);
96 let Ok(matches) = probe.try_get_matches_from_mut(raw) else {
97 return Ok(command);
98 };
99 let levels = levels(&probe, &matches, &command);
100 let placed = placed(&levels);
101 let mut unset = Vec::new();
102 let mut kept = Vec::<&Placed<'_>>::new();
103 for option in placed
104 .iter()
105 .filter(|option| option.arg.get_env().is_some())
106 {
107 let overruled = !option.is_set()
108 || option.given
109 || placed
110 .iter()
111 .any(|given| given.given && (option.negates(given) || option.conflicts(given)));
112 if overruled {
113 unset.push((option.depth, option.arg.get_id().clone()));
114 } else if let Some(other) = kept.iter().find(|other| option.conflicts(other)) {
115 let mut command = levels
116 .last()
117 .map_or_else(|| probe.clone(), |(level, _)| (*level).clone());
118 return Err(clap::Error::raw(
119 ErrorKind::ArgumentConflict,
120 format!(
121 "the variables `{}` and `{}` cannot both be set\n",
122 other.env_name(),
123 option.env_name()
124 ),
125 )
126 .format(&mut command));
127 } else {
128 kept.push(option);
129 }
130 }
131 let path = levels
132 .iter()
133 .skip(1)
134 .map(|(level, _)| level.get_name())
135 .collect::<Vec<_>>();
136 Ok(unbind(command, &path, 0, &unset))
137}
138
139fn levels<'a>(
142 mut probe: &'a Command,
143 mut matches: &'a ArgMatches,
144 mut declared: &Command,
145) -> Vec<(&'a Command, &'a ArgMatches)> {
146 let mut levels = vec![(probe, matches)];
147 while let Some((name, sub)) = matches.subcommand() {
148 let (Some(next), Some(next_declared)) =
149 (probe.find_subcommand(name), declared.find_subcommand(name))
150 else {
151 break;
152 };
153 probe = next;
154 matches = sub;
155 declared = next_declared;
156 levels.push((probe, matches));
157 }
158 levels
159}
160
161struct Placed<'a> {
163 depth: usize,
164 arg: &'a Arg,
165 conflicts: Vec<&'a Id>,
166 given: bool,
167}
168
169fn placed<'a>(levels: &[(&'a Command, &'a ArgMatches)]) -> Vec<Placed<'a>> {
176 let mut options = Vec::new();
177 for (depth, (level, matches)) in levels.iter().enumerate() {
178 let parent = depth.checked_sub(1).and_then(|up| levels.get(up));
179 for arg in level.get_arguments() {
180 let copied = arg.is_global_set()
181 && parent.is_some_and(|(up, _)| {
182 up.get_arguments()
183 .any(|other| other.get_id() == arg.get_id())
184 });
185 if copied {
186 continue;
187 }
188 let mut conflicts = level
189 .get_arg_conflicts_with(&arg.clone().global(false))
190 .into_iter()
191 .map(Arg::get_id)
192 .collect::<Vec<_>>();
193 for group in level.get_groups() {
194 if group.get_args().any(|id| id == arg.get_id()) && !group.clone().is_multiple() {
195 conflicts.extend(group.get_args().filter(|id| *id != arg.get_id()));
196 }
197 }
198 options.push(Placed {
199 depth,
200 arg,
201 conflicts,
202 given: matches.value_source(arg.get_id().as_str())
203 == Some(ValueSource::CommandLine),
204 });
205 }
206 }
207 options
208}
209
210impl Placed<'_> {
211 fn env_name(&self) -> String {
212 self.arg
213 .get_env()
214 .map(|name| name.to_string_lossy().into_owned())
215 .unwrap_or_default()
216 }
217
218 fn is_set(&self) -> bool {
220 let Some(value) = self.arg.get_env().and_then(std::env::var_os) else {
221 return false;
222 };
223 let action = self.arg.get_action();
224 let default = if matches!(action, ArgAction::SetTrue) {
225 Some("false")
226 } else if matches!(action, ArgAction::SetFalse) {
227 Some("true")
228 } else {
229 None
230 };
231 !value.is_empty() && default.is_none_or(|default| value != default)
232 }
233
234 fn meets(&self, other: &Self) -> bool {
237 let upper = if self.depth <= other.depth {
238 self
239 } else {
240 other
241 };
242 self.depth == other.depth || upper.arg.is_global_set()
243 }
244
245 fn conflicts(&self, other: &Self) -> bool {
247 let exclusive = self.arg.is_exclusive_set() || other.arg.is_exclusive_set();
248 self.meets(other)
249 && self.arg.get_id() != other.arg.get_id()
250 && (exclusive
251 || self.conflicts.contains(&other.arg.get_id())
252 || other.conflicts.contains(&self.arg.get_id()))
253 }
254
255 fn negates(&self, other: &Self) -> bool {
257 let (Some(long), Some(other_long)) = (self.arg.get_long(), other.arg.get_long()) else {
258 return false;
259 };
260 self.meets(other)
261 && (long.strip_prefix("no-") == Some(other_long)
262 || other_long.strip_prefix("no-") == Some(long))
263 }
264}
265
266fn unbind(command: Command, path: &[&str], depth: usize, unset: &[(usize, Id)]) -> Command {
268 let command = command.mut_args(|arg| {
269 if unset
270 .iter()
271 .any(|(at, id)| *at == depth && id == arg.get_id())
272 {
273 arg.env(None::<&'static str>)
274 } else {
275 arg
276 }
277 });
278 match path.split_first() {
279 Some((name, rest)) => command.mut_subcommands(|sub| {
280 if sub.get_name() == *name {
281 unbind(sub, rest, depth + 1, unset)
282 } else {
283 sub
284 }
285 }),
286 None => command,
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use clap::{Args, CommandFactory, Parser, Subcommand};
293
294 use super::{EnvPrefix, env_name, env_under_flags};
295
296 #[derive(Parser)]
297 #[command(env_prefix = "TOYCTL_UNIT")]
298 struct Toy {
299 #[arg(long, global = true)]
300 format: Option<String>,
301 #[arg(long)]
302 no_color: bool,
303 #[arg(long, hide = true)]
304 secret: bool,
305 #[arg(long, env = "OWN_NAME")]
306 token: Option<String>,
307 #[command(subcommand)]
308 command: Cmd,
309 }
310
311 #[derive(Subcommand)]
312 enum Cmd {
313 Prepare(PrepareArgs),
314 Group {
315 #[command(subcommand)]
316 command: Nested,
317 },
318 }
319
320 #[derive(Args)]
321 struct PrepareArgs {
322 #[arg(short = 'c', long)]
323 config: Option<String>,
324 path: Option<String>,
325 }
326
327 #[derive(Subcommand)]
328 enum Nested {
329 Show {
330 #[arg(long)]
331 dry_run: bool,
332 },
333 }
334
335 fn env_of(command: &clap::Command, id: &str) -> Option<String> {
336 command
337 .get_arguments()
338 .find(|arg| arg.get_id() == id)
339 .and_then(|arg| arg.get_env())
340 .map(|name| name.to_string_lossy().into_owned())
341 }
342
343 #[test]
344 fn a_long_name_becomes_the_prefixed_variable() {
345 assert_eq!(env_name("VERCTL", "dry-run"), "VERCTL_DRY_RUN");
346 assert_eq!(env_name("QCTL", "config"), "QCTL_CONFIG");
347 }
348
349 #[test]
350 fn every_eligible_long_option_is_bound_at_every_depth() {
351 let command = Toy::command();
352 assert_eq!(
353 env_of(&command, "format").as_deref(),
354 Some("TOYCTL_UNIT_FORMAT")
355 );
356 let prepare = command.find_subcommand("prepare").unwrap();
357 assert_eq!(
358 env_of(prepare, "config").as_deref(),
359 Some("TOYCTL_UNIT_CONFIG")
360 );
361 let show = command
362 .find_subcommand("group")
363 .and_then(|group| group.find_subcommand("show"))
364 .unwrap();
365 assert_eq!(
366 env_of(show, "dry_run").as_deref(),
367 Some("TOYCTL_UNIT_DRY_RUN")
368 );
369 }
370
371 #[test]
372 fn negations_hidden_positional_and_declared_options_keep_their_shape() {
373 let command = Toy::command();
374 assert_eq!(env_of(&command, "no_color"), None);
375 assert_eq!(env_of(&command, "secret"), None);
376 assert_eq!(env_of(&command, "token").as_deref(), Some("OWN_NAME"));
377 let prepare = command.find_subcommand("prepare").unwrap();
378 assert_eq!(env_of(prepare, "path"), None);
379 }
380
381 #[test]
382 fn a_global_option_that_conflicts_with_a_group_parses() {
383 let command = clap::Command::new("toy")
384 .arg(
385 clap::Arg::new("quiet")
386 .long("quiet")
387 .global(true)
388 .action(clap::ArgAction::SetTrue)
389 .conflicts_with("loud"),
390 )
391 .arg(
392 clap::Arg::new("verbose")
393 .long("verbose")
394 .action(clap::ArgAction::SetTrue),
395 )
396 .group(clap::ArgGroup::new("loud").arg("verbose"))
397 .env_prefix("TOYCTL_GROUP_UNIT");
398 let raw = ["toy", "--quiet"].map(std::ffi::OsString::from);
399 let matches = env_under_flags(command, &raw)
400 .unwrap()
401 .try_get_matches_from(&raw)
402 .unwrap();
403 assert!(matches.get_flag("quiet"));
404 }
405
406 #[test]
407 fn a_command_without_the_attribute_binds_nothing() {
408 let command = clap::Command::new("bare").arg(clap::Arg::new("format").long("format"));
409 assert_eq!(env_of(&command, "format"), None);
410 let bound = command.env_prefix("BARE");
411 assert_eq!(env_of(&bound, "format").as_deref(), Some("BARE_FORMAT"));
412 }
413}