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/// Hand the parsed spec's variables to a command we are about to spawn.
11///
12/// On Windows this is not just `Command::env`. The executable search order there puts the
13/// system directory ahead of `PATH`, so `bash` resolves to `C:\Windows\System32\bash.exe` —
14/// the WSL launcher — on any machine with WSL installed, whatever else is on `PATH`. WSL only
15/// carries a Win32 variable across the boundary if `WSLENV` names it, so without this the
16/// script runs with every `usage_*` variable unset, silently and with no error.
17pub fn apply_parsed_env(cmd: &mut Command, env: &BTreeMap<String, String>) {
18    for (key, val) in env {
19        cmd.env(key, val);
20    }
21    if env.is_empty() {
22        return;
23    }
24    // `cfg!` rather than `#[cfg(windows)]`: CI only runs on Linux, so a `#[cfg]` block here
25    // would never be compiled, type-checked or linted anywhere. This compiles everywhere and
26    // optimizes away off Windows.
27    if cfg!(windows) {
28        let existing = var("WSLENV").ok();
29        let keys = env.keys().map(String::as_str);
30        cmd.env("WSLENV", append_to_wslenv(existing.as_deref(), keys));
31    }
32}
33
34/// Whether an entry already in `WSLENV` delivers its value to WSL unchanged.
35///
36/// Only a bare name or `/u` does. Measured against WSL rather than read off the flag list,
37/// because two of them lose the value outright in this direction:
38///
39/// | entry     | `FOO=bar`        | `FOO=C:\Windows` |
40/// | --------- | ---------------- | ---------------- |
41/// | `FOO`     | `bar`            | `C:\Windows`     |
42/// | `FOO/u`   | `bar`            | `C:\Windows`     |
43/// | `FOO/w`   | *unset*          | *unset*          |
44/// | `FOO/uw`  | *unset*          | *unset*          |
45/// | `FOO/p`   | *unset*          | `/mnt/c/Windows` |
46/// | `FOO/l`   | *unset*          | `/mnt/c/Windows` |
47///
48/// `/w` is the other direction only, and `/p` and `/l` translate the value as a path — which
49/// drops anything that is not one. usage's values are arbitrary strings off a command line, so
50/// a `/p` entry would silently swallow almost all of them.
51fn carries_value_verbatim(entry: &str) -> bool {
52    match entry.split_once('/') {
53        None => true,
54        Some((_, flags)) => !flags.is_empty() && flags.chars().all(|flag| flag == 'u'),
55    }
56}
57
58/// Add `keys` to a `WSLENV` value, preserving whatever was already there.
59///
60/// `WSLENV` is a `:`-separated list of *variable names*, each optionally suffixed with flags.
61/// Names are added bare: usage has no idea whether a given value is a path, and `/p` would
62/// silently rewrite anything that merely looks like one. Unflagged names copy the value
63/// verbatim, so a script sees the same bytes it would on Unix.
64///
65/// Existing entries are never rewritten or dropped — a name the caller configured is theirs.
66/// But an existing entry for a name usage is about to set does not stop usage adding its own
67/// bare one unless it [carries the value verbatim](carries_value_verbatim): a `usage_foo/p`
68/// inherited from somewhere would otherwise mean the script sees nothing at all. Listing the
69/// name twice is how it is fixed rather than a problem to avoid — WSL takes the entry that
70/// transfers, so `FOO/p:FOO` arrives as plain `FOO`.
71///
72/// Takes the current value as an argument instead of reading the environment so it stays a
73/// pure function, testable on every platform rather than only where it does anything.
74pub fn append_to_wslenv<'a>(
75    existing: Option<&str>,
76    keys: impl IntoIterator<Item = &'a str>,
77) -> String {
78    let mut entries: Vec<&str> = vec![];
79    let mut names: HashSet<&str> = HashSet::new();
80
81    for entry in existing.unwrap_or_default().split(':') {
82        // Absorbs a leading, trailing or doubled `:`, either inherited or left by a caller
83        // that built the list by naive concatenation.
84        if entry.is_empty() {
85            continue;
86        }
87        if carries_value_verbatim(entry) {
88            names.insert(entry.split('/').next().unwrap_or(entry));
89        }
90        entries.push(entry);
91    }
92
93    for key in keys {
94        // A name carrying `:` or `/` would not just fail to transfer, it would corrupt the
95        // rest of the list and take the caller's own entries down with it. `as_env` derives
96        // names from `to_snake_case`, which cannot produce either, so this is a guard against
97        // that changing out from under us rather than a case we expect.
98        if key.is_empty() || key.contains(':') || key.contains('/') {
99            continue;
100        }
101        if names.insert(key) {
102            entries.push(key);
103        }
104    }
105
106    entries.join(":")
107}
108
109/// Keyed by the *program* rather than the subcommand, because that is what the value names.
110/// `usage powershell` runs `pwsh`, so its variable is `USAGE_SHELL_PWSH`.
111pub fn shell_var_name(shell: &str) -> String {
112    format!("USAGE_SHELL_{}", shell.to_ascii_uppercase())
113}
114
115/// The shell program to run in place of `shell`, if one was configured.
116///
117/// `None` means run `shell` as before. The value is a program path or a name to look up on
118/// `PATH` — not a command line: shells on Windows live at paths like
119/// `C:\Program Files\Git\bin\bash.exe`, and treating the value as a command line would make
120/// usage responsible for quoting rules it has no reason to own. `Command` passes the program
121/// and each argument separately, so a path with spaces needs no quoting.
122///
123/// An empty or blank value reads as unset, matching the `FOO= cmd` convention for switching
124/// something off. Nothing checks that the program exists: the value need not be an absolute
125/// path, so deciding would mean reimplementing `PATH`, `PATHEXT` and permission lookup, and
126/// racing the spawn that follows. A bad value surfaces as a spawn error naming it.
127///
128/// `lookup` is injected rather than read from the environment so this stays testable without
129/// mutating process-wide state — the same shape as `parse_partial_with_env` in usage-lib.
130pub fn shell_program_override(
131    shell: &str,
132    lookup: impl Fn(&str) -> Option<String>,
133) -> Option<String> {
134    let value = lookup(&shell_var_name(shell))?;
135    let value = value.trim();
136    (!value.is_empty()).then(|| value.to_string())
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    fn append(existing: Option<&str>, keys: &[&str]) -> String {
144        append_to_wslenv(existing, keys.iter().copied())
145    }
146
147    #[test]
148    fn wslenv_adds_keys_in_order() {
149        assert_eq!(append(None, &["usage_workspace"]), "usage_workspace");
150        assert_eq!(
151            append(None, &["usage_workspace", "usage_region"]),
152            "usage_workspace:usage_region"
153        );
154    }
155
156    #[test]
157    fn wslenv_appends_after_existing_entries() {
158        assert_eq!(append(Some("FOO"), &["usage_a"]), "FOO:usage_a");
159    }
160
161    #[test]
162    fn wslenv_leaves_existing_flags_untouched() {
163        assert_eq!(
164            append(Some("FOO/p:BAR/l"), &["usage_a"]),
165            "FOO/p:BAR/l:usage_a"
166        );
167    }
168
169    #[test]
170    fn wslenv_does_not_repeat_an_existing_name() {
171        assert_eq!(
172            append(Some("usage_a"), &["usage_a", "usage_b"]),
173            "usage_a:usage_b"
174        );
175    }
176
177    #[test]
178    fn wslenv_treats_a_direction_only_entry_as_covering_the_name() {
179        // `/u` is this direction — Win32 invoking WSL — so the value already arrives intact.
180        assert_eq!(append(Some("usage_a/u"), &["usage_a"]), "usage_a/u");
181    }
182
183    #[test]
184    fn wslenv_adds_its_own_entry_beside_one_that_would_lose_the_value() {
185        // `/p` translates the value as a path and drops anything that is not one; `/w` is the
186        // other direction entirely. Neither would deliver a parsed argument, so usage adds a
187        // bare entry after it — WSL then takes the one that transfers.
188        for flags in ["/p", "/l", "/w", "/uw"] {
189            let existing = format!("usage_a{flags}");
190            assert_eq!(
191                append(Some(&existing), &["usage_a"]),
192                format!("{existing}:usage_a"),
193                "an inherited {existing} must not swallow the value"
194            );
195        }
196    }
197
198    #[test]
199    fn carries_value_verbatim_only_for_bare_names_and_u() {
200        assert!(carries_value_verbatim("FOO"));
201        assert!(carries_value_verbatim("FOO/u"));
202        for entry in [
203            "FOO/p", "FOO/l", "FOO/w", "FOO/uw", "FOO/wu", "FOO/pu", "FOO/",
204        ] {
205            assert!(!carries_value_verbatim(entry), "{entry}");
206        }
207    }
208
209    #[test]
210    fn wslenv_drops_empty_segments() {
211        assert_eq!(append(Some(""), &["usage_a"]), "usage_a");
212        assert_eq!(append(Some("::FOO::"), &["usage_a"]), "FOO:usage_a");
213    }
214
215    #[test]
216    fn wslenv_with_no_keys_returns_existing() {
217        assert_eq!(append(Some("FOO"), &[]), "FOO");
218        assert_eq!(append(None, &[]), "");
219    }
220
221    #[test]
222    fn wslenv_skips_keys_that_would_corrupt_the_list() {
223        assert_eq!(append(None, &["ok", "bad:name"]), "ok");
224        assert_eq!(append(None, &["ok", "bad/p"]), "ok");
225        assert_eq!(append(None, &["", "ok"]), "ok");
226    }
227
228    #[test]
229    fn wslenv_dedups_within_the_new_keys() {
230        assert_eq!(append(None, &["a", "a"]), "a");
231    }
232
233    #[test]
234    fn wslenv_adds_no_flags() {
235        // Values are arbitrary strings, not known to be paths, so they must cross verbatim.
236        assert!(!append(None, &["usage_a"]).contains('/'));
237    }
238
239    #[test]
240    fn parsed_env_keys_are_safe_for_wslenv() {
241        // `append_to_wslenv` skips names containing `:` or `/`. Nothing usage-lib produces
242        // should ever hit that path; if `as_env` starts generating such names, variables
243        // would go missing on Windows, so pin the invariant here.
244        let spec: usage::Spec = r#"
245            arg "<some file>"
246            flag "--dry-run"
247            "#
248        .parse()
249        .unwrap();
250        let args = ["test", "x", "--dry-run"].map(String::from);
251        let env = usage::parse(&spec, &args).unwrap().as_env();
252
253        assert!(!env.is_empty());
254        for key in env.keys() {
255            assert!(
256                !key.is_empty() && !key.contains(':') && !key.contains('/'),
257                "as_env produced a key that cannot go in WSLENV: {key}"
258            );
259        }
260    }
261
262    fn from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
263        let pairs: Vec<(String, String)> = pairs
264            .iter()
265            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
266            .collect();
267        move |key| {
268            pairs
269                .iter()
270                .find(|(k, _)| k == key)
271                .map(|(_, v)| v.to_string())
272        }
273    }
274
275    #[test]
276    fn shell_var_names_cover_every_shell_subcommand() {
277        // These are the four programs `Cli::run` dispatches to; `powershell` runs `pwsh`.
278        assert_eq!(shell_var_name("bash"), "USAGE_SHELL_BASH");
279        assert_eq!(shell_var_name("zsh"), "USAGE_SHELL_ZSH");
280        assert_eq!(shell_var_name("fish"), "USAGE_SHELL_FISH");
281        assert_eq!(shell_var_name("pwsh"), "USAGE_SHELL_PWSH");
282    }
283
284    #[test]
285    fn unset_means_no_override() {
286        assert_eq!(shell_program_override("bash", from(&[])), None);
287    }
288
289    #[test]
290    fn a_blank_value_means_no_override() {
291        for blank in ["", "   ", "\t"] {
292            assert_eq!(
293                shell_program_override("bash", from(&[("USAGE_SHELL_BASH", blank)])),
294                None,
295                "blank value {blank:?} should read as unset"
296            );
297        }
298    }
299
300    #[test]
301    fn surrounding_whitespace_is_trimmed() {
302        assert_eq!(
303            shell_program_override("bash", from(&[("USAGE_SHELL_BASH", "  /usr/bin/bash  ")])),
304            Some("/usr/bin/bash".to_string())
305        );
306    }
307
308    #[test]
309    fn a_path_with_spaces_survives_intact() {
310        let path = r"C:\Program Files\Git\bin\bash.exe";
311        assert_eq!(
312            shell_program_override("bash", from(&[("USAGE_SHELL_BASH", path)])),
313            Some(path.to_string())
314        );
315    }
316
317    #[test]
318    fn another_shells_variable_is_not_picked_up() {
319        assert_eq!(
320            shell_program_override("bash", from(&[("USAGE_SHELL_ZSH", "/bin/zsh")])),
321            None
322        );
323    }
324}