1use std::ffi::OsString;
9
10use clap::error::ErrorKind;
11use clap::parser::ValueSource;
12use clap::{Arg, ArgAction, ArgMatches, Command, Id};
13
14pub trait EnvPrefix: sealed::Sealed {
41 #[must_use]
43 fn env_prefix(self, prefix: &str) -> Self;
44}
45
46mod sealed {
49 pub trait Sealed {}
52
53 impl Sealed for clap::Command {}
54}
55
56impl EnvPrefix for Command {
57 fn env_prefix(self, prefix: &str) -> Self {
58 self.mut_args(|arg| bind(arg, prefix))
59 .mut_subcommands(|subcommand| subcommand.env_prefix(prefix))
60 }
61}
62
63#[must_use]
66fn env_name(prefix: &str, long: &str) -> String {
67 format!("{prefix}_{}", long.to_ascii_uppercase().replace('-', "_"))
68}
69
70fn bind(arg: Arg, prefix: &str) -> Arg {
71 let name = match arg.get_long() {
72 Some(long)
73 if arg.get_env().is_none()
74 && !arg.is_hide_set()
75 && !long.starts_with("no-")
76 && !matches!(
77 arg.get_action(),
78 ArgAction::Help
79 | ArgAction::HelpShort
80 | ArgAction::HelpLong
81 | ArgAction::Version
82 ) =>
83 {
84 env_name(prefix, long)
85 }
86 _ => return arg,
87 };
88 arg.env(name)
89}
90
91pub fn env_under_flags(command: Command, raw: &[OsString]) -> Result<Command, clap::Error> {
122 let mut probe = command.clone().ignore_errors(true);
123 let Ok(matches) = probe.try_get_matches_from_mut(raw) else {
124 return Ok(command);
125 };
126 let levels = levels(&probe, &matches, &command);
127 let placed = placed(&levels);
128 let mut unset = Vec::new();
129 let mut kept = Vec::<&Placed<'_>>::new();
130 for option in placed
131 .iter()
132 .filter(|option| option.arg.get_env().is_some())
133 {
134 let overruled = !option.is_set()
135 || option.given
136 || placed
137 .iter()
138 .any(|given| given.given && (option.negates(given) || option.conflicts(given)));
139 if overruled {
140 unset.push((option.depth, option.arg.get_id().clone()));
141 } else if let Some(other) = kept.iter().find(|other| option.conflicts(other)) {
142 let mut command = levels
143 .last()
144 .map_or_else(|| probe.clone(), |(level, _)| (*level).clone());
145 return Err(clap::Error::raw(
146 ErrorKind::ArgumentConflict,
147 format!(
148 "the variables `{}` and `{}` cannot both be set\n",
149 other.env_name(),
150 option.env_name()
151 ),
152 )
153 .format(&mut command));
154 } else {
155 kept.push(option);
156 }
157 }
158 let path = levels
159 .iter()
160 .skip(1)
161 .map(|(level, _)| level.get_name())
162 .collect::<Vec<_>>();
163 Ok(unbind(command, &path, 0, &unset))
164}
165
166fn levels<'a>(
169 mut probe: &'a Command,
170 mut matches: &'a ArgMatches,
171 mut declared: &Command,
172) -> Vec<(&'a Command, &'a ArgMatches)> {
173 let mut levels = vec![(probe, matches)];
174 while let Some((name, sub)) = matches.subcommand() {
175 let (Some(next), Some(next_declared)) =
176 (probe.find_subcommand(name), declared.find_subcommand(name))
177 else {
178 break;
179 };
180 probe = next;
181 matches = sub;
182 declared = next_declared;
183 levels.push((probe, matches));
184 }
185 levels
186}
187
188struct Placed<'a> {
190 depth: usize,
191 arg: &'a Arg,
192 conflicts: Vec<&'a Id>,
193 given: bool,
194}
195
196fn placed<'a>(levels: &[(&'a Command, &'a ArgMatches)]) -> Vec<Placed<'a>> {
203 let mut options = Vec::new();
204 for (depth, (level, matches)) in levels.iter().enumerate() {
205 let parent = depth.checked_sub(1).and_then(|up| levels.get(up));
206 for arg in level.get_arguments() {
207 let copied = arg.is_global_set()
208 && parent.is_some_and(|(up, _)| {
209 up.get_arguments()
210 .any(|other| other.get_id() == arg.get_id())
211 });
212 if copied {
213 continue;
214 }
215 let mut conflicts = level
216 .get_arg_conflicts_with(&arg.clone().global(false))
217 .into_iter()
218 .map(Arg::get_id)
219 .collect::<Vec<_>>();
220 for group in level.get_groups() {
221 if group.get_args().any(|id| id == arg.get_id()) && !group.clone().is_multiple() {
222 conflicts.extend(group.get_args().filter(|id| *id != arg.get_id()));
223 }
224 }
225 options.push(Placed {
226 depth,
227 arg,
228 conflicts,
229 given: matches.value_source(arg.get_id().as_str())
230 == Some(ValueSource::CommandLine),
231 });
232 }
233 }
234 options
235}
236
237impl Placed<'_> {
238 fn env_name(&self) -> String {
239 self.arg
240 .get_env()
241 .map(|name| name.to_string_lossy().into_owned())
242 .unwrap_or_default()
243 }
244
245 fn is_set(&self) -> bool {
247 let Some(value) = self.arg.get_env().and_then(std::env::var_os) else {
248 return false;
249 };
250 let action = self.arg.get_action();
251 let default = if matches!(action, ArgAction::SetTrue) {
252 Some("false")
253 } else if matches!(action, ArgAction::SetFalse) {
254 Some("true")
255 } else {
256 None
257 };
258 !value.is_empty() && default.is_none_or(|default| value != default)
259 }
260
261 fn meets(&self, other: &Self) -> bool {
264 let upper = if self.depth <= other.depth {
265 self
266 } else {
267 other
268 };
269 self.depth == other.depth || upper.arg.is_global_set()
270 }
271
272 fn conflicts(&self, other: &Self) -> bool {
274 let exclusive = self.arg.is_exclusive_set() || other.arg.is_exclusive_set();
275 self.meets(other)
276 && self.arg.get_id() != other.arg.get_id()
277 && (exclusive
278 || self.conflicts.contains(&other.arg.get_id())
279 || other.conflicts.contains(&self.arg.get_id()))
280 }
281
282 fn negates(&self, other: &Self) -> bool {
284 let (Some(long), Some(other_long)) = (self.arg.get_long(), other.arg.get_long()) else {
285 return false;
286 };
287 self.meets(other)
288 && (long.strip_prefix("no-") == Some(other_long)
289 || other_long.strip_prefix("no-") == Some(long))
290 }
291}
292
293fn unbind(command: Command, path: &[&str], depth: usize, unset: &[(usize, Id)]) -> Command {
295 let command = command.mut_args(|arg| {
296 if unset
297 .iter()
298 .any(|(at, id)| *at == depth && id == arg.get_id())
299 {
300 arg.env(None::<&'static str>)
301 } else {
302 arg
303 }
304 });
305 match path.split_first() {
306 Some((name, rest)) => command.mut_subcommands(|sub| {
307 if sub.get_name() == *name {
308 unbind(sub, rest, depth + 1, unset)
309 } else {
310 sub
311 }
312 }),
313 None => command,
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use clap::{Args, CommandFactory, Parser, Subcommand};
320
321 use super::{EnvPrefix, env_name, env_under_flags};
322
323 #[derive(Parser)]
324 #[command(env_prefix = "TOYCTL_UNIT")]
325 struct Toy {
326 #[arg(long, global = true)]
327 format: Option<String>,
328 #[arg(long)]
329 no_color: bool,
330 #[arg(long, hide = true)]
331 secret: bool,
332 #[arg(long, env = "OWN_NAME")]
333 token: Option<String>,
334 #[command(subcommand)]
335 command: Cmd,
336 }
337
338 #[derive(Subcommand)]
339 enum Cmd {
340 Prepare(PrepareArgs),
341 Group {
342 #[command(subcommand)]
343 command: Nested,
344 },
345 }
346
347 #[derive(Args)]
348 struct PrepareArgs {
349 #[arg(short = 'c', long)]
350 config: Option<String>,
351 path: Option<String>,
352 }
353
354 #[derive(Subcommand)]
355 enum Nested {
356 Show {
357 #[arg(long)]
358 dry_run: bool,
359 },
360 }
361
362 fn env_of(command: &clap::Command, id: &str) -> Option<String> {
363 command
364 .get_arguments()
365 .find(|arg| arg.get_id() == id)
366 .and_then(|arg| arg.get_env())
367 .map(|name| name.to_string_lossy().into_owned())
368 }
369
370 #[test]
371 fn a_long_name_becomes_the_prefixed_variable() {
372 assert_eq!(env_name("VERCTL", "dry-run"), "VERCTL_DRY_RUN");
373 assert_eq!(env_name("QCTL", "config"), "QCTL_CONFIG");
374 }
375
376 #[test]
377 fn every_eligible_long_option_is_bound_at_every_depth() {
378 let command = Toy::command();
379 assert_eq!(
380 env_of(&command, "format").as_deref(),
381 Some("TOYCTL_UNIT_FORMAT")
382 );
383 let prepare = command.find_subcommand("prepare").unwrap();
384 assert_eq!(
385 env_of(prepare, "config").as_deref(),
386 Some("TOYCTL_UNIT_CONFIG")
387 );
388 let show = command
389 .find_subcommand("group")
390 .and_then(|group| group.find_subcommand("show"))
391 .unwrap();
392 assert_eq!(
393 env_of(show, "dry_run").as_deref(),
394 Some("TOYCTL_UNIT_DRY_RUN")
395 );
396 }
397
398 #[test]
399 fn negations_hidden_positional_and_declared_options_keep_their_shape() {
400 let command = Toy::command();
401 assert_eq!(env_of(&command, "no_color"), None);
402 assert_eq!(env_of(&command, "secret"), None);
403 assert_eq!(env_of(&command, "token").as_deref(), Some("OWN_NAME"));
404 let prepare = command.find_subcommand("prepare").unwrap();
405 assert_eq!(env_of(prepare, "path"), None);
406 }
407
408 #[test]
409 fn a_global_option_that_conflicts_with_a_group_parses() {
410 let command = clap::Command::new("toy")
411 .arg(
412 clap::Arg::new("quiet")
413 .long("quiet")
414 .global(true)
415 .action(clap::ArgAction::SetTrue)
416 .conflicts_with("loud"),
417 )
418 .arg(
419 clap::Arg::new("verbose")
420 .long("verbose")
421 .action(clap::ArgAction::SetTrue),
422 )
423 .group(clap::ArgGroup::new("loud").arg("verbose"))
424 .env_prefix("TOYCTL_GROUP_UNIT");
425 let raw = ["toy", "--quiet"].map(std::ffi::OsString::from);
426 let matches = env_under_flags(command, &raw)
427 .unwrap()
428 .try_get_matches_from(&raw)
429 .unwrap();
430 assert!(matches.get_flag("quiet"));
431 }
432
433 #[test]
434 fn a_command_without_the_attribute_binds_nothing() {
435 let command = clap::Command::new("bare").arg(clap::Arg::new("format").long("format"));
436 assert_eq!(env_of(&command, "format"), None);
437 let bound = command.env_prefix("BARE");
438 assert_eq!(env_of(&bound, "format").as_deref(), Some("BARE_FORMAT"));
439 }
440}