Skip to main content

ctl_core/flags/
env.rs

1//! Bind every long option to an environment variable.
2//!
3//! [`EnvPrefix`] binds a command's options to `<PREFIX>_<LONG>`, and
4//! [`env_under_flags`] makes a flag on the command line win over its variable.
5//! A CLI uses the first through `#[command(env_prefix = "...")]` and gets the
6//! second from [`App`](crate::App).
7
8use std::ffi::OsString;
9
10use clap::error::ErrorKind;
11use clap::parser::ValueSource;
12use clap::{Arg, ArgAction, ArgMatches, Command, Id};
13
14/// Binds each long option of a [`Command`] and of every subcommand to
15/// `<PREFIX>_<LONG>`, so the environment can set any flag a CLI declares.
16///
17/// Put it on the root parser as a derive attribute, with the trait in scope
18/// (the prelude exports it): `#[command(env_prefix = "TOY")]`.
19///
20/// Clap's derive applies a root attribute after it has added every argument
21/// and subcommand. So `C::command()` returns the bound graph to help, the
22/// Usage spec, the pre-parse view, and the parse alike.
23///
24/// A command-line flag wins over its variable, which wins over the default,
25/// once [`env_under_flags`] has run before the parse. A CLI that also reads a
26/// config file applies a file value only where neither was given.
27///
28/// An option is left unbound when any of these holds:
29///
30/// - it already declares its own `env`, which is the override;
31/// - it has no long name, or is hidden;
32/// - it is the `--no-*` side of a pair;
33/// - it is help or version.
34///
35/// Not calling `env_prefix` disables the binding.
36///
37/// The trait is sealed: [`Command`] is its only implementation, and a type
38/// outside ctl-core cannot add one. So a method can join it in a minor
39/// release.
40pub trait EnvPrefix: sealed::Sealed {
41    /// Bind every eligible long option under `prefix`.
42    #[must_use]
43    fn env_prefix(self, prefix: &str) -> Self;
44}
45
46/// Holds the supertrait that keeps [`EnvPrefix`] to the types ctl-core
47/// implements it for.
48mod sealed {
49    /// Implemented only inside ctl-core. Its path is private, so no other crate
50    /// can name it.
51    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/// The variable a long option reads: `dry-run` under `TOY` is
64/// `TOY_DRY_RUN`.
65#[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
91/// Removes each variable that the command line overrules, so the command line
92/// wins, and refuses two variables that conflict.
93///
94/// Clap treats a variable as given by the user. A variable that conflicts
95/// with a flag on the command line, or with its `--no-*` side, fails the parse
96/// ([clap#5591](https://github.com/clap-rs/clap/issues/5591)). This function
97/// parses `raw` once leniently to learn which options the command line set at
98/// each subcommand level. It then unbinds each variable that:
99///
100/// 1. belongs to an option the command line set;
101/// 2. belongs to the other side of a `--x`/`--no-x` pair the command line set;
102/// 3. belongs to an option that conflicts with one the command line set. The
103///    conflict may be a `conflicts_with` on either side, a single-choice group
104///    they share, or an `exclusive` option on either side;
105/// 4. is empty. A wrapper that maps every setting to a variable may set one the
106///    caller left out. A GitHub Actions composite action does this with an
107///    input it was not given;
108/// 5. reads as a boolean flag's default, `false` for a flag that sets `true`,
109///    so it changes nothing and cannot conflict.
110///
111/// Clap keeps `overrides_with` and a group's own `conflicts_with` private. So
112/// this function sees overrides only between `--x`/`--no-x` pairs, and no
113/// conflict that a group declares. Call it on the command just before the
114/// parse, and parse the command it returns. `App` does both.
115///
116/// # Errors
117///
118/// An `ArgumentConflict` error that names both variables when two remaining
119/// variables belong to conflicting options. Clap's own error would name two
120/// flags the user never typed.
121pub 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
166/// The probe's command and matches at each level of the subcommand path,
167/// as far as the declared command has that subcommand.
168fn 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
188/// One option on the subcommand path, at the level that declares it.
189struct Placed<'a> {
190    depth: usize,
191    arg: &'a Arg,
192    conflicts: Vec<&'a Id>,
193    given: bool,
194}
195
196/// Every option on the path once. A global option appears at the level that
197/// declares it, not at the levels clap copies it into.
198///
199/// Conflicts are read through a non-global copy of each option. For a global,
200/// clap's lookup resolves only argument ids and panics on a group id. The
201/// declaring level holds every id its conflicts may name.
202fn 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    /// Whether the variable holds a value that changes the parse.
246    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    /// Whether the two options can meet: at one level, or one is a global
262    /// declared above the other.
263    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    /// Whether clap would refuse the two options together.
273    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    /// Whether the two options are the two sides of a `--x`/`--no-x` pair.
283    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
293/// Unbind each listed option at its depth along the subcommand path.
294fn 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}