Skip to main content

ctl_core/flags/
env.rs

1//! Bind every long option to an environment variable.
2
3use std::ffi::OsString;
4
5use clap::error::ErrorKind;
6use clap::parser::ValueSource;
7use clap::{Arg, ArgAction, ArgMatches, Command, Id};
8
9/// Binds each long option of a [`Command`] and of every subcommand to
10/// `<PREFIX>_<LONG>`, so the environment can set any flag a CLI declares.
11///
12/// Put it on the root parser as a derive attribute, with the trait in scope
13/// (the prelude exports it): `#[command(env_prefix = "VERCTL")]`. Clap's
14/// derive applies a root attribute after it has added every argument and
15/// subcommand, so `C::command()` returns the bound graph to help, the Usage
16/// spec, the pre-parse view, and the parse alike.
17///
18/// A command-line flag wins over its variable, which wins over the default,
19/// once [`env_under_flags`] has run before the parse. A CLI that also reads a
20/// config file applies a file value only where neither was given.
21///
22/// An option is left unbound when it already declares its own `env` (the
23/// override), has no long name, is hidden, is the `--no-*` side of a pair,
24/// or is help or version. Not calling `env_prefix` disables the binding.
25pub trait EnvPrefix {
26    /// Bind every eligible long option under `prefix`.
27    #[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/// The variable a long option reads: `dry-run` under `VERCTL` is
39/// `VERCTL_DRY_RUN`.
40#[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
66/// Removes each variable that the command line overrules, so the command line
67/// wins, and refuses two variables that conflict.
68///
69/// Clap treats a variable as given by the user: a variable that conflicts
70/// with a flag on the command line, or with its `--no-*` side, fails the
71/// parse ([clap#5591](https://github.com/clap-rs/clap/issues/5591)). This
72/// parses `raw` once leniently to learn which options the command line set
73/// at each subcommand level, then unbinds each variable that:
74///
75/// 1. belongs to an option the command line set;
76/// 2. belongs to the other side of a `--x`/`--no-x` pair the command line set;
77/// 3. belongs to an option that conflicts, either way, with one the command
78///    line set: a `conflicts_with` on either side, a single-choice group they
79///    share, or either side being `exclusive`;
80/// 4. is empty, as a composite action sets an input it was not given;
81/// 5. reads as a boolean flag's default, `false` for a flag that sets `true`,
82///    so it changes nothing and cannot conflict.
83///
84/// Clap keeps `overrides_with` and a group's own `conflicts_with` private, so
85/// this sees overrides only between `--x`/`--no-x` pairs, and no conflict
86/// that a group declares. Call this on the command just before the parse,
87/// and parse the command it returns; `App` does.
88///
89/// # Errors
90///
91/// An `ArgumentConflict` error that names both variables when two remaining
92/// variables belong to conflicting options. Clap's own error would name two
93/// flags the user never typed.
94pub 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
139/// The probe's command and matches at each level of the subcommand path,
140/// as far as the declared command has that subcommand.
141fn 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
161/// One option on the subcommand path, at the level that declares it.
162struct Placed<'a> {
163    depth: usize,
164    arg: &'a Arg,
165    conflicts: Vec<&'a Id>,
166    given: bool,
167}
168
169/// Every option on the path once. A global option appears at the level that
170/// declares it, not at the levels clap copies it into.
171///
172/// Conflicts are read through a non-global copy of each option: for a global,
173/// clap's lookup resolves only argument ids and panics on a group id, while
174/// the declaring level holds every id its conflicts may name.
175fn 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    /// Whether the variable holds a value that changes the parse.
219    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    /// Whether the two options can meet: at one level, or one is a global
235    /// declared above the other.
236    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    /// Whether clap would refuse the two options together.
246    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    /// Whether the two options are the two sides of a `--x`/`--no-x` pair.
256    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
266/// Unbind each listed option at its depth along the subcommand path.
267fn 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}