Skip to main content

agentmux_srv/backend/
shells.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The shells installed on this machine, and which one a new local terminal
5//! runs: the default-shell setting
6//! (docs/specs/SPEC_DEFAULT_SHELL_SETTING_2026_10_13.md).
7//!
8//! - [`list`]: every shell found, with a name and version, for Settings.
9//! - [`resolve_for_spawn`]: the shell a terminal starts with. The block's own
10//!   `term:localshellpath`, else the global setting, else the automatic choice
11//!   (PowerShell 7, then Windows PowerShell, then Command Prompt on Windows;
12//!   `$SHELL`, then `/bin/bash` elsewhere). A chosen shell that isn't there
13//!   falls back to the automatic one, so a stale setting never stops a
14//!   terminal from opening.
15//!
16//! Finding candidates is written over an injected environment and filesystem,
17//! with Windows paths handled as strings, like `posix_shell::resolve_windows`,
18//! so the order is tested on every host.
19
20use std::collections::HashSet;
21use std::ffi::OsString;
22use std::path::{Path, PathBuf};
23use std::sync::Mutex;
24use std::time::{Duration, Instant};
25
26use crate::backend::blockcontroller::{META_KEY_TERM_LOCAL_SHELL_OPTS, META_KEY_TERM_LOCAL_SHELL_PATH};
27use crate::backend::obj::MetaMapType;
28use crate::backend::rpc_types::{EffectiveShell, ShellInfo, ShellList};
29use crate::backend::wconfig::SettingsType;
30
31/// How long a shell may take to say its version before it is listed without one.
32const VERSION_TIMEOUT: Duration = Duration::from_secs(3);
33/// How long a listing is reused: finding versions runs every shell once.
34const CACHE_TTL: Duration = Duration::from_secs(30);
35
36const SEPARATORS: [char; 2] = ['\\', '/'];
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39enum Kind {
40    Pwsh,
41    PowerShell,
42    Cmd,
43    GitBash,
44    Bash,
45    Zsh,
46    Fish,
47    Nu,
48    Sh,
49    Other,
50}
51
52/// A path's file name, on either separator, so a Windows path reads the same on any host.
53fn file_name(path: &Path) -> String {
54    let s = path.to_string_lossy();
55    s.rsplit(SEPARATORS).next().unwrap_or(&s).to_string()
56}
57
58impl Kind {
59    fn of(path: &Path) -> Kind {
60        let lower = path.to_string_lossy().to_ascii_lowercase();
61        let name = file_name(Path::new(&lower));
62        let stem = name.strip_suffix(".exe").unwrap_or(&name);
63        let in_git = lower.split(SEPARATORS).any(|part| part == "git");
64        match stem {
65            "pwsh" => Kind::Pwsh,
66            "powershell" => Kind::PowerShell,
67            "cmd" => Kind::Cmd,
68            "bash" if in_git => Kind::GitBash,
69            "bash" => Kind::Bash,
70            "zsh" => Kind::Zsh,
71            "fish" => Kind::Fish,
72            "nu" => Kind::Nu,
73            "sh" | "dash" => Kind::Sh,
74            _ => Kind::Other,
75        }
76    }
77
78    fn id(self) -> &'static str {
79        match self {
80            Kind::Pwsh => "pwsh",
81            Kind::PowerShell => "powershell",
82            Kind::Cmd => "cmd",
83            Kind::GitBash => "gitbash",
84            Kind::Bash => "bash",
85            Kind::Zsh => "zsh",
86            Kind::Fish => "fish",
87            Kind::Nu => "nu",
88            Kind::Sh => "sh",
89            Kind::Other => "other",
90        }
91    }
92
93    fn name(self, path: &Path) -> String {
94        match self {
95            Kind::Pwsh => "PowerShell".into(),
96            Kind::PowerShell => "Windows PowerShell".into(),
97            Kind::Cmd => "Command Prompt".into(),
98            Kind::GitBash => "Git Bash".into(),
99            Kind::Bash => "Bash".into(),
100            Kind::Zsh => "Zsh".into(),
101            Kind::Fish => "fish".into(),
102            Kind::Nu => "Nushell".into(),
103            Kind::Sh | Kind::Other => file_name(path),
104        }
105    }
106}
107
108/// A shell found, before its version is asked.
109#[derive(Debug, Clone, PartialEq, Eq)]
110struct Candidate {
111    path: PathBuf,
112    kind: Kind,
113}
114
115fn candidate(path: PathBuf) -> Candidate {
116    let kind = Kind::of(&path);
117    Candidate { path, kind }
118}
119
120/// Drop repeats: Windows paths compare case-insensitively, and the same file
121/// can be reached twice (`/bin/bash` and `/usr/bin/bash` on a merged-usr system).
122fn dedupe(cands: Vec<Candidate>, canonical: &dyn Fn(&Path) -> PathBuf) -> Vec<Candidate> {
123    let mut seen = HashSet::new();
124    cands
125        .into_iter()
126        .filter(|c| seen.insert(canonical(&c.path).to_string_lossy().to_ascii_lowercase()))
127        .collect()
128}
129
130/// `base\rel`, built as a string: `PathBuf::join` would use `/` when the tests
131/// run on a Unix host.
132fn wjoin(base: &str, rel: &str) -> String {
133    format!("{}\\{}", base.trim_end_matches(SEPARATORS), rel)
134}
135
136/// The folder holding a Windows path, on either separator.
137fn wparent(p: &str) -> Option<&str> {
138    let p = p.trim_end_matches(SEPARATORS);
139    p.rfind(SEPARATORS).map(|i| &p[..i])
140}
141
142/// Windows candidates, most familiar first: PowerShell 7, Windows PowerShell,
143/// Command Prompt, Git Bash, MSYS2 and Cygwin bash, then Nushell and fish on PATH.
144#[cfg_attr(not(windows), allow(dead_code))]
145fn windows_candidates(env: &dyn Fn(&str) -> Option<OsString>, is_file: &dyn Fn(&Path) -> bool) -> Vec<Candidate> {
146    let var = |k: &str| env(k).map(|v| v.to_string_lossy().into_owned()).filter(|v| !v.is_empty());
147    let path: Vec<String> = var("PATH")
148        .map(|v| {
149            v.split(';')
150                .map(|s| s.trim().trim_matches('"').to_string())
151                .filter(|s| !s.is_empty())
152                .collect()
153        })
154        .unwrap_or_default();
155    let is = |p: &str| is_file(Path::new(p));
156    let on_path = |exe: &str| path.iter().map(|d| wjoin(d, exe)).filter(|p| is(p)).collect::<Vec<_>>();
157    let program_roots: Vec<String> = ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"]
158        .into_iter()
159        .filter_map(var)
160        .collect();
161    let system_root = var("SystemRoot").unwrap_or_else(|| r"C:\Windows".to_string());
162
163    let mut out: Vec<String> = Vec::new();
164    // PowerShell 7: PATH (an MSI or winget install), its default install
165    // folders, and the Store's alias.
166    out.extend(on_path("pwsh.exe"));
167    for root in &program_roots {
168        for dir in ["7", "7-preview"] {
169            out.push(wjoin(root, &format!(r"PowerShell\{dir}\pwsh.exe")));
170        }
171    }
172    if let Some(local) = var("LOCALAPPDATA") {
173        out.push(wjoin(&local, r"Microsoft\WindowsApps\pwsh.exe"));
174    }
175    out.push(wjoin(&system_root, r"System32\WindowsPowerShell\v1.0\powershell.exe"));
176    out.push(var("ComSpec").unwrap_or_else(|| wjoin(&system_root, r"System32\cmd.exe")));
177    // Git Bash: Git's `bin\bash.exe` launcher (it sets up the MSYS PATH), from
178    // the install that owns the `git.exe` on PATH (at <root>\cmd, <root>\bin
179    // or <root>\mingw64\bin), then the standard roots.
180    for git in on_path("git.exe") {
181        let mut dir = wparent(&git).and_then(wparent);
182        for _ in 0..3 {
183            let Some(root) = dir else { break };
184            out.push(wjoin(root, r"bin\bash.exe"));
185            dir = wparent(root);
186        }
187    }
188    for root in &program_roots {
189        out.push(wjoin(root, r"Git\bin\bash.exe"));
190    }
191    if let Some(local) = var("LOCALAPPDATA") {
192        out.push(wjoin(&local, r"Programs\Git\bin\bash.exe"));
193    }
194    out.push(r"C:\msys64\usr\bin\bash.exe".to_string());
195    out.push(r"C:\cygwin64\bin\bash.exe".to_string());
196    out.extend(on_path("nu.exe"));
197    out.extend(on_path("fish.exe"));
198    out.into_iter().filter(|p| is(p)).map(|p| candidate(PathBuf::from(p))).collect()
199}
200
201/// macOS and Linux candidates: the login shell, then `/etc/shells`, then
202/// Homebrew's shells that `/etc/shells` doesn't list.
203#[cfg_attr(windows, allow(dead_code))]
204fn unix_candidates(login: Option<PathBuf>, etc_shells: &str, is_file: &dyn Fn(&Path) -> bool) -> Vec<Candidate> {
205    let mut out: Vec<PathBuf> = login.into_iter().collect();
206    out.extend(
207        etc_shells
208            .lines()
209            .map(str::trim)
210            .filter(|l| l.starts_with('/'))
211            .map(PathBuf::from),
212    );
213    for dir in ["/opt/homebrew/bin", "/usr/local/bin"] {
214        for sh in ["zsh", "bash", "fish", "nu"] {
215            out.push(Path::new(dir).join(sh));
216        }
217    }
218    out.into_iter()
219        .filter(|p| is_file(p))
220        // Not shells to log in with.
221        .filter(|p| !matches!(file_name(p).as_str(), "nologin" | "false" | "git-shell"))
222        .map(candidate)
223        .collect()
224}
225
226fn real_env(k: &str) -> Option<OsString> {
227    std::env::var_os(k)
228}
229
230fn real_is_file(p: &Path) -> bool {
231    p.is_file()
232}
233
234fn real_canonical(p: &Path) -> PathBuf {
235    std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
236}
237
238fn candidates() -> Vec<Candidate> {
239    let found = if cfg!(windows) {
240        windows_candidates(&real_env, &real_is_file)
241    } else {
242        let login = std::env::var_os("SHELL").filter(|s| !s.is_empty()).map(PathBuf::from);
243        let etc = std::fs::read_to_string("/etc/shells").unwrap_or_default();
244        unix_candidates(login, &etc, &real_is_file)
245    };
246    dedupe(found, &real_canonical)
247}
248
249/// The automatic choice, as before the setting was honoured: PowerShell 7 on
250/// PATH, else Windows PowerShell, else Command Prompt; elsewhere `$SHELL`,
251/// else `/bin/bash`.
252pub fn automatic() -> PathBuf {
253    if cfg!(windows) {
254        for exe in ["pwsh", "powershell"] {
255            if let Ok(p) = which::which(exe) {
256                return p;
257            }
258        }
259        std::env::var_os("ComSpec")
260            .filter(|s| !s.is_empty())
261            .map(PathBuf::from)
262            .unwrap_or_else(|| PathBuf::from("cmd.exe"))
263    } else {
264        std::env::var_os("SHELL")
265            .filter(|s| !s.is_empty())
266            .map(PathBuf::from)
267            .unwrap_or_else(|| PathBuf::from("/bin/bash"))
268    }
269}
270
271/// A configured shell, as an existing file: a path that exists, or a bare name
272/// (`pwsh`, `nu`) found on PATH.
273fn locate(configured: &str) -> Option<PathBuf> {
274    if configured.contains(SEPARATORS) {
275        let p = PathBuf::from(configured);
276        p.is_file().then_some(p)
277    } else {
278        which::which(configured).ok()
279    }
280}
281
282/// The shell a new local terminal starts with.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct SpawnShell {
285    pub path: PathBuf,
286    /// `block`, `setting`, `automatic`, or `fallback` (a configured shell wasn't found).
287    pub source: &'static str,
288    /// The configured shell that wasn't found, for `fallback`.
289    pub missing: Option<String>,
290}
291
292/// Which shell a terminal starts with: the block's `term:localshellpath`, then
293/// the global setting, then [`automatic`].
294pub fn resolve_for_spawn(block_setting: &str, global_setting: &str) -> SpawnShell {
295    resolve_with(block_setting, global_setting, &locate, &automatic)
296}
297
298fn resolve_with(
299    block_setting: &str,
300    global_setting: &str,
301    locate: &dyn Fn(&str) -> Option<PathBuf>,
302    automatic: &dyn Fn() -> PathBuf,
303) -> SpawnShell {
304    let (configured, source) = match (block_setting.trim(), global_setting.trim()) {
305        (b, _) if !b.is_empty() => (b, "block"),
306        (_, g) if !g.is_empty() => (g, "setting"),
307        _ => return SpawnShell { path: automatic(), source: "automatic", missing: None },
308    };
309    match locate(configured) {
310        Some(path) => SpawnShell { path, source, missing: None },
311        None => SpawnShell { path: automatic(), source: "fallback", missing: Some(configured.to_string()) },
312    }
313}
314
315/// The shell and extra arguments a block's interactive terminal starts with:
316/// [`resolve_for_spawn`] on the block's and the global `term:localshellpath`,
317/// and the block's `term:localshellopts`, else the setting's (appended after
318/// the shell-integration arguments). Logs a configured shell that wasn't found.
319pub fn for_block(block_id: &str, block_meta: &MetaMapType, settings: &SettingsType) -> (PathBuf, Vec<String>) {
320    let own_path = block_meta.get(META_KEY_TERM_LOCAL_SHELL_PATH).and_then(|v| v.as_str()).unwrap_or("");
321    let shell = resolve_for_spawn(own_path, &settings.term_local_shell_path);
322    if let Some(missing) = &shell.missing {
323        tracing::warn!(block_id, missing = %missing, using = %shell.path.display(), "configured shell not found; using the automatic one");
324    }
325    (shell.path, shell_opts(block_meta, &settings.term_local_shell_opts))
326}
327
328/// The block's own `term:localshellopts` when it has the key, even empty (an
329/// agent's shell pins `[]`), else the setting's.
330fn shell_opts(block_meta: &MetaMapType, setting: &[String]) -> Vec<String> {
331    match block_meta.get(META_KEY_TERM_LOCAL_SHELL_OPTS).and_then(|v| v.as_array()) {
332        Some(own) => own.iter().filter_map(|x| x.as_str().map(str::to_string)).collect(),
333        None => setting.to_vec(),
334    }
335}
336
337/// The shell's arguments: the integration ones and the user's
338/// `term:localshellopts`. PowerShell's integration ends `-File <script>`, and
339/// anything after `-File` goes to the script, so the user's options go before
340/// it; elsewhere they follow (bash wants its long options, `--rcfile`, first).
341pub fn with_user_opts(integration: &[String], opts: &[String]) -> Vec<String> {
342    let at = integration
343        .iter()
344        .position(|a| a.eq_ignore_ascii_case("-File"))
345        .unwrap_or(integration.len());
346    let mut out = integration[..at].to_vec();
347    out.extend_from_slice(opts);
348    out.extend_from_slice(&integration[at..]);
349    out
350}
351
352/// Ask a shell its version, briefly.
353fn version_of(c: &Candidate) -> Option<String> {
354    let args: &[&str] = match c.kind {
355        Kind::Pwsh | Kind::PowerShell => &["-NoLogo", "-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
356        Kind::Cmd | Kind::Other => return None,
357        _ => &["--version"],
358    };
359    let mut cmd = std::process::Command::new(&c.path);
360    cmd.args(args)
361        .stdin(std::process::Stdio::null())
362        .stdout(std::process::Stdio::piped())
363        .stderr(std::process::Stdio::null());
364    #[cfg(windows)]
365    {
366        use agentmux_common::win32::NoWindow;
367        cmd.no_window();
368    }
369    let mut child = cmd.spawn().ok()?;
370    let deadline = Instant::now() + VERSION_TIMEOUT;
371    loop {
372        match child.try_wait() {
373            Ok(Some(_)) => break,
374            Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
375            _ => {
376                let _ = child.kill();
377                let _ = child.wait();
378                return None;
379            }
380        }
381    }
382    let out = child.wait_with_output().ok()?;
383    let text = String::from_utf8_lossy(&out.stdout);
384    crate::backend::cli_notice::version_token(text.lines().next().unwrap_or_default())
385}
386
387static CACHE: Mutex<Option<(Instant, Vec<ShellInfo>)>> = Mutex::new(None);
388
389/// Every shell found, with versions asked in parallel. Reused for [`CACHE_TTL`].
390fn installed() -> Vec<ShellInfo> {
391    if let Some((at, shells)) = CACHE.lock().unwrap().as_ref() {
392        if at.elapsed() < CACHE_TTL {
393            return shells.clone();
394        }
395    }
396    let cands = candidates();
397    let versions: Vec<Option<String>> = std::thread::scope(|s| {
398        let handles: Vec<_> = cands.iter().map(|c| s.spawn(move || version_of(c))).collect();
399        handles.into_iter().map(|h| h.join().ok().flatten()).collect()
400    });
401    let shells: Vec<ShellInfo> = cands
402        .iter()
403        .zip(versions)
404        .map(|(c, version)| ShellInfo {
405            path: c.path.display().to_string(),
406            name: c.kind.name(&c.path),
407            kind: c.kind.id().to_string(),
408            version,
409        })
410        .collect();
411    *CACHE.lock().unwrap() = Some((Instant::now(), shells.clone()));
412    shells
413}
414
415/// What Settings shows: the shells found, the automatic choice, and what a new
416/// terminal starts with under `global_setting`. Blocking: run it off the
417/// async runtime.
418pub fn list(global_setting: &str) -> ShellList {
419    let shells = installed();
420    let spawn = resolve_for_spawn("", global_setting);
421    ShellList {
422        shells,
423        automatic: automatic().display().to_string(),
424        effective: EffectiveShell {
425            path: spawn.path.display().to_string(),
426            source: spawn.source.to_string(),
427            missing: spawn.missing,
428        },
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use std::collections::HashMap;
436
437    fn env_of(vars: &[(&str, &str)]) -> impl Fn(&str) -> Option<OsString> {
438        let m: HashMap<String, String> = vars.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
439        move |k: &str| m.get(k).map(OsString::from)
440    }
441
442    fn files(paths: &[&str]) -> impl Fn(&Path) -> bool {
443        let set: HashSet<String> = paths.iter().map(|p| p.to_ascii_lowercase()).collect();
444        move |p: &Path| set.contains(&p.to_string_lossy().to_ascii_lowercase())
445    }
446
447    #[test]
448    fn windows_lists_the_familiar_shells_in_order() {
449        let env = env_of(&[
450            ("PATH", r"C:\Program Files\PowerShell\7;C:\Program Files\Git\cmd;C:\tools\nu"),
451            ("ProgramFiles", r"C:\Program Files"),
452            ("SystemRoot", r"C:\Windows"),
453            ("ComSpec", r"C:\Windows\System32\cmd.exe"),
454        ]);
455        let is_file = files(&[
456            r"C:\Program Files\PowerShell\7\pwsh.exe",
457            r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
458            r"C:\Windows\System32\cmd.exe",
459            r"C:\Program Files\Git\cmd\git.exe",
460            r"C:\Program Files\Git\bin\bash.exe",
461            r"C:\tools\nu\nu.exe",
462        ]);
463        let found = dedupe(windows_candidates(&env, &is_file), &|p| p.to_path_buf());
464        let kinds: Vec<&str> = found.iter().map(|c| c.kind.id()).collect();
465        assert_eq!(kinds, ["pwsh", "powershell", "cmd", "gitbash", "nu"]);
466    }
467
468    #[test]
469    fn windows_lists_nothing_that_isnt_there() {
470        let env = env_of(&[("SystemRoot", r"C:\Windows")]);
471        let found = windows_candidates(&env, &files(&[r"C:\Windows\System32\cmd.exe"]));
472        assert_eq!(found.iter().map(|c| c.kind.id()).collect::<Vec<_>>(), ["cmd"]);
473    }
474
475    #[test]
476    fn unix_lists_the_login_shell_first_and_skips_non_shells() {
477        let etc = "# /etc/shells\n/bin/sh\n/bin/bash\n/usr/bin/zsh\n/usr/sbin/nologin\n/bin/false\n";
478        let is_file = files(&["/bin/sh", "/bin/bash", "/usr/bin/zsh", "/usr/sbin/nologin", "/bin/false"]);
479        let found = unix_candidates(Some(PathBuf::from("/usr/bin/zsh")), etc, &is_file);
480        let found = dedupe(found, &|p| p.to_path_buf());
481        let paths: Vec<String> = found.iter().map(|c| c.path.display().to_string()).collect();
482        assert_eq!(paths, ["/usr/bin/zsh", "/bin/sh", "/bin/bash"]);
483    }
484
485    #[test]
486    fn the_same_file_reached_twice_is_listed_once() {
487        let etc = "/bin/bash\n/usr/bin/bash\n";
488        let is_file = files(&["/bin/bash", "/usr/bin/bash"]);
489        let canonical = |p: &Path| {
490            if p == Path::new("/bin/bash") {
491                PathBuf::from("/usr/bin/bash")
492            } else {
493                p.to_path_buf()
494            }
495        };
496        let found = dedupe(unix_candidates(None, etc, &is_file), &canonical);
497        assert_eq!(found.len(), 1);
498    }
499
500    #[test]
501    fn names_say_what_the_shell_is() {
502        assert_eq!(Kind::of(Path::new(r"C:\Program Files\Git\bin\bash.exe")), Kind::GitBash);
503        assert_eq!(Kind::of(Path::new(r"C:\msys64\usr\bin\bash.exe")), Kind::Bash);
504        assert_eq!(Kind::of(Path::new("/bin/bash")), Kind::Bash);
505        assert_eq!(Kind::of(Path::new(r"C:\Windows\System32\cmd.exe")), Kind::Cmd);
506        assert_eq!(Kind::GitBash.name(Path::new("x")), "Git Bash");
507        assert_eq!(Kind::Other.name(Path::new(r"C:\tools\xonsh.exe")), "xonsh.exe");
508    }
509
510    #[test]
511    fn the_blocks_own_options_win_over_the_setting() {
512        let mut meta = MetaMapType::new();
513        let setting = vec!["-NoLogo".to_string()];
514        assert_eq!(shell_opts(&meta, &setting), setting);
515        meta.insert(META_KEY_TERM_LOCAL_SHELL_OPTS.into(), serde_json::json!(["-l"]));
516        assert_eq!(shell_opts(&meta, &setting), vec!["-l".to_string()]);
517        // An empty list of its own means none, not the setting's.
518        meta.insert(META_KEY_TERM_LOCAL_SHELL_OPTS.into(), serde_json::json!([]));
519        assert!(shell_opts(&meta, &setting).is_empty());
520    }
521
522    fn strings(v: &[&str]) -> Vec<String> {
523        v.iter().map(|s| s.to_string()).collect()
524    }
525
526    #[test]
527    fn user_options_go_before_powershells_file_and_after_bashs_rcfile() {
528        let pwsh = strings(&["-ExecutionPolicy", "Bypass", "-NoExit", "-File", "wavepwsh.ps1"]);
529        assert_eq!(
530            with_user_opts(&pwsh, &strings(&["-NoLogo"])),
531            strings(&["-ExecutionPolicy", "Bypass", "-NoExit", "-NoLogo", "-File", "wavepwsh.ps1"])
532        );
533        let bash = strings(&["--rcfile", "bashrc"]);
534        assert_eq!(with_user_opts(&bash, &strings(&["-l"])), strings(&["--rcfile", "bashrc", "-l"]));
535        assert_eq!(with_user_opts(&[], &strings(&["/K"])), strings(&["/K"]));
536    }
537
538    fn auto() -> PathBuf {
539        PathBuf::from("auto-shell")
540    }
541
542    #[test]
543    fn spawn_uses_the_block_then_the_setting_then_automatic() {
544        let locate = |s: &str| Some(PathBuf::from(s));
545        assert_eq!(resolve_with("blk", "glob", &locate, &auto).source, "block");
546        assert_eq!(resolve_with("", "glob", &locate, &auto).path, PathBuf::from("glob"));
547        let a = resolve_with("", "  ", &locate, &auto);
548        assert_eq!((a.path, a.source), (auto(), "automatic"));
549    }
550
551    #[test]
552    fn a_shell_that_isnt_there_falls_back_to_automatic_and_says_so() {
553        let locate = |_: &str| None;
554        let r = resolve_with("", r"C:\gone\nu.exe", &locate, &auto);
555        assert_eq!(r.path, auto());
556        assert_eq!(r.source, "fallback");
557        assert_eq!(r.missing.as_deref(), Some(r"C:\gone\nu.exe"));
558    }
559}