Skip to main content

usage_cli/
env.rs

1use std::collections::{BTreeMap, HashSet};
2use std::process::Command;
3
4pub use std::env::*;
5
6pub fn var_true(key: &str) -> bool {
7    matches!(var(key), Ok(v) if v == "1" || v == "true")
8}
9
10/// The prefix this CLI's own settings live under.
11///
12/// Not `USAGE_`, which is the same namespace the parser writes a spec's values into
13/// (`usage_<arg>`). On Windows environment variable names are case-insensitive, so
14/// `USAGE_DEBUG` and a spec's `usage_debug` are one variable there, and two things follow:
15/// a script with an ordinary `--debug` flag cannot read `$usage_debug` when the setting is
16/// also set, and mise — which clears `usage_*` before a task so its own parsed arguments
17/// cannot leak in, comparing the first six characters case-insensitively — takes the
18/// settings with it. Six characters is why no `USAGE_…` spelling escapes: `USAGE_CLI_` and
19/// `USAGE_SETTING_` are cleared just the same.
20const SETTING_PREFIX: &str = "USAGECLI_";
21
22/// The prefix the settings used to live under, still read so nothing that set it breaks.
23const LEGACY_SETTING_PREFIX: &str = "USAGE_";
24
25/// The environment variable naming setting `name`, e.g. `SHELL_BASH` -> `USAGECLI_SHELL_BASH`.
26pub fn setting_var_name(name: &str) -> String {
27    format!("{SETTING_PREFIX}{name}")
28}
29
30/// The value of setting `name`, preferring the current spelling over the legacy one.
31///
32/// First one set wins and the rest are not looked at, which is what makes the old name an
33/// alias rather than a second setting — the same order `usage-config` gives `deprecated_envs`.
34/// Nothing is warned about: one name set is the ordinary case, and the settings read here are
35/// read before there is a logger to warn with.
36///
37/// An empty or blank value reads as unset at each name, matching the `FOO= cmd` convention for
38/// switching something off, so blanking the current name falls through to the legacy one
39/// rather than to nothing.
40pub fn setting(name: &str, lookup: impl Fn(&str) -> Option<String>) -> Option<String> {
41    setting_entry(name, lookup).map(|(_, value)| value)
42}
43
44/// As [`setting`], and also which spelling supplied it.
45///
46/// The name matters to anything that reports back: telling someone their `USAGECLI_SHELL_BASH`
47/// could not be started, when what they set was `USAGE_SHELL_BASH`, sends them looking at a
48/// variable they never touched.
49pub fn setting_entry(
50    name: &str,
51    lookup: impl Fn(&str) -> Option<String>,
52) -> Option<(String, String)> {
53    [
54        format!("{SETTING_PREFIX}{name}"),
55        format!("{LEGACY_SETTING_PREFIX}{name}"),
56    ]
57    .into_iter()
58    .find_map(|key| {
59        let value = lookup(&key)?;
60        let value = value.trim();
61        (!value.is_empty()).then(|| (key, value.to_string()))
62    })
63}
64
65/// The log filter to use, under either spelling of each setting.
66///
67/// Resolved rather than read straight by `env_logger`: `Env::filter_or` falls back to its
68/// default only when the variable is *unset*, so a blank `USAGECLI_LOG` would be taken as the
69/// filter instead of falling through to `USAGE_LOG` — which is the rule [`setting`] promises.
70///
71/// Precedence: trace over debug over an explicit level, and `info` if none of them says
72/// otherwise.
73pub fn log_filter(lookup: impl Fn(&str) -> Option<String>) -> String {
74    // By reference, so the caller's closure need not be `Copy` — `&F` is itself `Fn` when `F`
75    // is, which is what lets one lookup answer all three settings.
76    let on = |name: &str| matches!(setting(name, &lookup), Some(v) if v == "1" || v == "true");
77    if on("TRACE") {
78        return "trace".to_string();
79    }
80    if on("DEBUG") {
81        return "debug".to_string();
82    }
83    setting("LOG", &lookup).unwrap_or_else(|| "info".to_string())
84}
85
86/// Hand the parsed spec's variables to a command we are about to spawn.
87///
88/// On Windows this is not just `Command::env`. The executable search order there puts the
89/// system directory ahead of `PATH`, so `bash` resolves to `C:\Windows\System32\bash.exe` —
90/// the WSL launcher — on any machine with WSL installed, whatever else is on `PATH`. WSL only
91/// carries a Win32 variable across the boundary if `WSLENV` names it, so without this the
92/// script runs with every `usage_*` variable unset, silently and with no error.
93pub fn apply_parsed_env(cmd: &mut Command, env: &BTreeMap<String, String>) {
94    for (key, val) in env {
95        cmd.env(key, val);
96    }
97    // `usage_cmd` is the subcommand path this parse chose, and absent when it chose none. A
98    // script run from another usage script would otherwise inherit the caller's path and
99    // dispatch on a subcommand nobody typed.
100    if !env.contains_key("usage_cmd") {
101        cmd.env_remove("usage_cmd");
102    }
103    if env.is_empty() {
104        return;
105    }
106    // `cfg!` rather than `#[cfg(windows)]`: CI only runs on Linux, so a `#[cfg]` block here
107    // would never be compiled, type-checked or linted anywhere. This compiles everywhere and
108    // optimizes away off Windows.
109    if cfg!(windows) {
110        let existing = var("WSLENV").ok();
111        let keys = env.keys().map(String::as_str);
112        cmd.env("WSLENV", append_to_wslenv(existing.as_deref(), keys));
113    }
114}
115
116/// Whether an entry already in `WSLENV` delivers its value to WSL unchanged.
117///
118/// Only a bare name or `/u` does. Measured against WSL rather than read off the flag list,
119/// because two of them lose the value outright in this direction:
120///
121/// | entry     | `FOO=bar`        | `FOO=C:\Windows` |
122/// | --------- | ---------------- | ---------------- |
123/// | `FOO`     | `bar`            | `C:\Windows`     |
124/// | `FOO/u`   | `bar`            | `C:\Windows`     |
125/// | `FOO/w`   | *unset*          | *unset*          |
126/// | `FOO/uw`  | *unset*          | *unset*          |
127/// | `FOO/p`   | *unset*          | `/mnt/c/Windows` |
128/// | `FOO/l`   | *unset*          | `/mnt/c/Windows` |
129///
130/// `/w` is the other direction only, and `/p` and `/l` translate the value as a path — which
131/// drops anything that is not one. usage's values are arbitrary strings off a command line, so
132/// a `/p` entry would silently swallow almost all of them.
133fn carries_value_verbatim(entry: &str) -> bool {
134    match entry.split_once('/') {
135        None => true,
136        Some((_, flags)) => !flags.is_empty() && flags.chars().all(|flag| flag == 'u'),
137    }
138}
139
140/// Add `keys` to a `WSLENV` value, preserving whatever was already there.
141///
142/// `WSLENV` is a `:`-separated list of *variable names*, each optionally suffixed with flags.
143/// Names are added bare: usage has no idea whether a given value is a path, and `/p` would
144/// silently rewrite anything that merely looks like one. Unflagged names copy the value
145/// verbatim, so a script sees the same bytes it would on Unix.
146///
147/// Existing entries are never rewritten or dropped — a name the caller configured is theirs.
148/// But an existing entry for a name usage is about to set does not stop usage adding its own
149/// bare one unless it [carries the value verbatim](carries_value_verbatim): a `usage_foo/p`
150/// inherited from somewhere would otherwise mean the script sees nothing at all. Listing the
151/// name twice is how it is fixed rather than a problem to avoid — WSL takes the entry that
152/// transfers, so `FOO/p:FOO` arrives as plain `FOO`.
153///
154/// Takes the current value as an argument instead of reading the environment so it stays a
155/// pure function, testable on every platform rather than only where it does anything.
156pub fn append_to_wslenv<'a>(
157    existing: Option<&str>,
158    keys: impl IntoIterator<Item = &'a str>,
159) -> String {
160    let mut entries: Vec<&str> = vec![];
161    let mut names: HashSet<&str> = HashSet::new();
162
163    for entry in existing.unwrap_or_default().split(':') {
164        // Absorbs a leading, trailing or doubled `:`, either inherited or left by a caller
165        // that built the list by naive concatenation.
166        if entry.is_empty() {
167            continue;
168        }
169        if carries_value_verbatim(entry) {
170            names.insert(entry.split('/').next().unwrap_or(entry));
171        }
172        entries.push(entry);
173    }
174
175    for key in keys {
176        // A name carrying `:` or `/` would not just fail to transfer, it would corrupt the
177        // rest of the list and take the caller's own entries down with it. `as_env` derives
178        // names from `to_snake_case`, which cannot produce either, so this is a guard against
179        // that changing out from under us rather than a case we expect.
180        if key.is_empty() || key.contains(':') || key.contains('/') {
181            continue;
182        }
183        if names.insert(key) {
184            entries.push(key);
185        }
186    }
187
188    entries.join(":")
189}
190
191/// Keyed by the *program* rather than the subcommand, because that is what the value names.
192/// `usage powershell` runs `pwsh`, so its variable is `USAGECLI_SHELL_PWSH`.
193///
194/// The current spelling, since this is what error messages tell people to set. The legacy
195/// `USAGE_SHELL_*` is still read — see [`setting`].
196pub fn shell_var_name(shell: &str) -> String {
197    setting_var_name(&shell_setting_name(shell))
198}
199
200fn shell_setting_name(shell: &str) -> String {
201    format!("SHELL_{}", shell.to_ascii_uppercase())
202}
203
204/// The shell program to run in place of `shell`, if one was configured.
205///
206/// `None` means run `shell` as before. The value is a program path or a name to look up on
207/// `PATH` — not a command line: shells on Windows live at paths like
208/// `C:\Program Files\Git\bin\bash.exe`, and treating the value as a command line would make
209/// usage responsible for quoting rules it has no reason to own. `Command` passes the program
210/// and each argument separately, so a path with spaces needs no quoting.
211///
212/// An empty or blank value reads as unset, matching the `FOO= cmd` convention for switching
213/// something off. Nothing checks that the program exists: the value need not be an absolute
214/// path, so deciding would mean reimplementing `PATH`, `PATHEXT` and permission lookup, and
215/// racing the spawn that follows. A bad value surfaces as a spawn error naming it.
216///
217/// `lookup` is injected rather than read from the environment so this stays testable without
218/// mutating process-wide state — the same shape as `parse_partial_with_env` in usage-lib.
219pub fn shell_program_override(
220    shell: &str,
221    lookup: impl Fn(&str) -> Option<String>,
222) -> Option<String> {
223    setting(&shell_setting_name(shell), lookup)
224}
225
226/// As [`shell_program_override`], and also the variable that named it.
227pub fn shell_program_override_entry(
228    shell: &str,
229    lookup: impl Fn(&str) -> Option<String>,
230) -> Option<(String, String)> {
231    setting_entry(&shell_setting_name(shell), lookup)
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    fn append(existing: Option<&str>, keys: &[&str]) -> String {
239        append_to_wslenv(existing, keys.iter().copied())
240    }
241
242    #[test]
243    fn wslenv_adds_keys_in_order() {
244        assert_eq!(append(None, &["usage_workspace"]), "usage_workspace");
245        assert_eq!(
246            append(None, &["usage_workspace", "usage_region"]),
247            "usage_workspace:usage_region"
248        );
249    }
250
251    #[test]
252    fn wslenv_appends_after_existing_entries() {
253        assert_eq!(append(Some("FOO"), &["usage_a"]), "FOO:usage_a");
254    }
255
256    #[test]
257    fn wslenv_leaves_existing_flags_untouched() {
258        assert_eq!(
259            append(Some("FOO/p:BAR/l"), &["usage_a"]),
260            "FOO/p:BAR/l:usage_a"
261        );
262    }
263
264    #[test]
265    fn wslenv_does_not_repeat_an_existing_name() {
266        assert_eq!(
267            append(Some("usage_a"), &["usage_a", "usage_b"]),
268            "usage_a:usage_b"
269        );
270    }
271
272    #[test]
273    fn wslenv_treats_a_direction_only_entry_as_covering_the_name() {
274        // `/u` is this direction — Win32 invoking WSL — so the value already arrives intact.
275        assert_eq!(append(Some("usage_a/u"), &["usage_a"]), "usage_a/u");
276    }
277
278    #[test]
279    fn wslenv_adds_its_own_entry_beside_one_that_would_lose_the_value() {
280        // `/p` translates the value as a path and drops anything that is not one; `/w` is the
281        // other direction entirely. Neither would deliver a parsed argument, so usage adds a
282        // bare entry after it — WSL then takes the one that transfers.
283        for flags in ["/p", "/l", "/w", "/uw"] {
284            let existing = format!("usage_a{flags}");
285            assert_eq!(
286                append(Some(&existing), &["usage_a"]),
287                format!("{existing}:usage_a"),
288                "an inherited {existing} must not swallow the value"
289            );
290        }
291    }
292
293    #[test]
294    fn carries_value_verbatim_only_for_bare_names_and_u() {
295        assert!(carries_value_verbatim("FOO"));
296        assert!(carries_value_verbatim("FOO/u"));
297        for entry in [
298            "FOO/p", "FOO/l", "FOO/w", "FOO/uw", "FOO/wu", "FOO/pu", "FOO/",
299        ] {
300            assert!(!carries_value_verbatim(entry), "{entry}");
301        }
302    }
303
304    #[test]
305    fn wslenv_drops_empty_segments() {
306        assert_eq!(append(Some(""), &["usage_a"]), "usage_a");
307        assert_eq!(append(Some("::FOO::"), &["usage_a"]), "FOO:usage_a");
308    }
309
310    #[test]
311    fn wslenv_with_no_keys_returns_existing() {
312        assert_eq!(append(Some("FOO"), &[]), "FOO");
313        assert_eq!(append(None, &[]), "");
314    }
315
316    #[test]
317    fn wslenv_skips_keys_that_would_corrupt_the_list() {
318        assert_eq!(append(None, &["ok", "bad:name"]), "ok");
319        assert_eq!(append(None, &["ok", "bad/p"]), "ok");
320        assert_eq!(append(None, &["", "ok"]), "ok");
321    }
322
323    #[test]
324    fn wslenv_dedups_within_the_new_keys() {
325        assert_eq!(append(None, &["a", "a"]), "a");
326    }
327
328    #[test]
329    fn wslenv_adds_no_flags() {
330        // Values are arbitrary strings, not known to be paths, so they must cross verbatim.
331        assert!(!append(None, &["usage_a"]).contains('/'));
332    }
333
334    #[test]
335    fn parsed_env_keys_are_safe_for_wslenv() {
336        // `append_to_wslenv` skips names containing `:` or `/`. Nothing usage-lib produces
337        // should ever hit that path; if `as_env` starts generating such names, variables
338        // would go missing on Windows, so pin the invariant here.
339        let spec: usage::Spec = r#"
340            arg "<some file>"
341            flag "--dry-run"
342            "#
343        .parse()
344        .unwrap();
345        let args = ["test", "x", "--dry-run"].map(String::from);
346        let env = usage::parse(&spec, &args).unwrap().as_env();
347
348        assert!(!env.is_empty());
349        for key in env.keys() {
350            assert!(
351                !key.is_empty() && !key.contains(':') && !key.contains('/'),
352                "as_env produced a key that cannot go in WSLENV: {key}"
353            );
354        }
355    }
356
357    fn from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
358        let pairs: Vec<(String, String)> = pairs
359            .iter()
360            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
361            .collect();
362        move |key| {
363            pairs
364                .iter()
365                .find(|(k, _)| k == key)
366                .map(|(_, v)| v.to_string())
367        }
368    }
369
370    #[test]
371    fn shell_var_names_cover_every_shell_subcommand() {
372        // These are the four programs `Cli::run` dispatches to; `powershell` runs `pwsh`.
373        assert_eq!(shell_var_name("bash"), "USAGECLI_SHELL_BASH");
374        assert_eq!(shell_var_name("zsh"), "USAGECLI_SHELL_ZSH");
375        assert_eq!(shell_var_name("fish"), "USAGECLI_SHELL_FISH");
376        assert_eq!(shell_var_name("pwsh"), "USAGECLI_SHELL_PWSH");
377    }
378
379    #[test]
380    fn the_prefix_is_one_mise_does_not_clear() {
381        // mise clears a task's `usage_*` so its own parsed arguments cannot leak in, comparing
382        // the first six characters case-insensitively. Everything this CLI wants to survive
383        // that has to differ inside those six — which is the whole reason for the spelling.
384        for shell in ["bash", "zsh", "fish", "pwsh"] {
385            let name = shell_var_name(shell);
386            assert!(
387                !name[.."usage_".len()].eq_ignore_ascii_case("usage_"),
388                "{name} would be cleared before a mise task ran"
389            );
390        }
391    }
392
393    #[test]
394    fn unset_means_no_override() {
395        assert_eq!(shell_program_override("bash", from(&[])), None);
396    }
397
398    #[test]
399    fn a_blank_value_means_no_override() {
400        for blank in ["", "   ", "\t"] {
401            assert_eq!(
402                shell_program_override("bash", from(&[("USAGE_SHELL_BASH", blank)])),
403                None,
404                "blank value {blank:?} should read as unset"
405            );
406        }
407    }
408
409    #[test]
410    fn surrounding_whitespace_is_trimmed() {
411        assert_eq!(
412            shell_program_override("bash", from(&[("USAGE_SHELL_BASH", "  /usr/bin/bash  ")])),
413            Some("/usr/bin/bash".to_string())
414        );
415    }
416
417    #[test]
418    fn a_path_with_spaces_survives_intact() {
419        let path = r"C:\Program Files\Git\bin\bash.exe";
420        assert_eq!(
421            shell_program_override("bash", from(&[("USAGE_SHELL_BASH", path)])),
422            Some(path.to_string())
423        );
424    }
425
426    #[test]
427    fn another_shells_variable_is_not_picked_up() {
428        assert_eq!(
429            shell_program_override("bash", from(&[("USAGE_SHELL_ZSH", "/bin/zsh")])),
430            None
431        );
432        assert_eq!(
433            shell_program_override("bash", from(&[("USAGECLI_SHELL_ZSH", "/bin/zsh")])),
434            None
435        );
436    }
437
438    // The tests above read the legacy name, which is the point: they were written before the
439    // rename and still pass, so they are what says nothing that set it has broken.
440
441    #[test]
442    fn the_current_name_is_read() {
443        assert_eq!(
444            shell_program_override("bash", from(&[("USAGECLI_SHELL_BASH", "/usr/bin/bash")])),
445            Some("/usr/bin/bash".to_string())
446        );
447    }
448
449    #[test]
450    fn the_current_name_wins_over_the_legacy_one() {
451        assert_eq!(
452            shell_program_override(
453                "bash",
454                from(&[
455                    ("USAGECLI_SHELL_BASH", "/current"),
456                    ("USAGE_SHELL_BASH", "/legacy"),
457                ])
458            ),
459            Some("/current".to_string())
460        );
461    }
462
463    #[test]
464    fn a_blanked_current_name_falls_through_to_the_legacy_one() {
465        // Blank reads as unset at each name rather than at the setting, so `USAGECLI_X= ` does
466        // not switch off a legacy `USAGE_X` the way it switches off its own.
467        assert_eq!(
468            shell_program_override(
469                "bash",
470                from(&[
471                    ("USAGECLI_SHELL_BASH", "  "),
472                    ("USAGE_SHELL_BASH", "/legacy")
473                ])
474            ),
475            Some("/legacy".to_string())
476        );
477    }
478
479    #[test]
480    fn the_log_filter_falls_back_through_both_spellings() {
481        assert_eq!(log_filter(from(&[])), "info");
482        assert_eq!(log_filter(from(&[("USAGE_LOG", "warn")])), "warn");
483        assert_eq!(log_filter(from(&[("USAGECLI_LOG", "warn")])), "warn");
484        // A blank current name is unset at that name, so the legacy one still answers. This is
485        // what `Env::filter_or` could not do: its default applies only to an *unset* variable,
486        // so a blank `USAGECLI_LOG` would have been taken as the filter itself.
487        assert_eq!(
488            log_filter(from(&[("USAGECLI_LOG", "  "), ("USAGE_LOG", "warn")])),
489            "warn"
490        );
491    }
492
493    #[test]
494    fn the_log_filter_keeps_trace_over_debug_over_a_level() {
495        assert_eq!(
496            log_filter(from(&[("USAGE_DEBUG", "1"), ("USAGE_LOG", "warn")])),
497            "debug"
498        );
499        assert_eq!(
500            log_filter(from(&[("USAGECLI_TRACE", "true"), ("USAGE_DEBUG", "1")])),
501            "trace"
502        );
503        // Only `1` and `true` switch it on; anything else is not a level request.
504        assert_eq!(log_filter(from(&[("USAGECLI_DEBUG", "0")])), "info");
505    }
506
507    #[test]
508    fn a_failed_lookup_reports_the_name_that_answered() {
509        assert_eq!(
510            setting_entry("SHELL_BASH", from(&[("USAGE_SHELL_BASH", "/legacy")])),
511            Some(("USAGE_SHELL_BASH".to_string(), "/legacy".to_string()))
512        );
513        assert_eq!(
514            setting_entry("SHELL_BASH", from(&[("USAGECLI_SHELL_BASH", "/current")])),
515            Some(("USAGECLI_SHELL_BASH".to_string(), "/current".to_string()))
516        );
517    }
518
519    #[test]
520    fn settings_are_not_limited_to_shells() {
521        // The same resolution serves USAGECLI_DEBUG, _TRACE and _LOG, which `main` reads.
522        assert_eq!(
523            setting("DEBUG", from(&[("USAGE_DEBUG", "1")])),
524            Some("1".to_string())
525        );
526        assert_eq!(
527            setting(
528                "LOG",
529                from(&[("USAGECLI_LOG", "trace"), ("USAGE_LOG", "debug")])
530            ),
531            Some("trace".to_string())
532        );
533        assert_eq!(setting("TRACE", from(&[])), None);
534    }
535}