Skip to main content

agentmux_common/
toolchain_path.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! GUI-launch PATH enrichment.
5//!
6//! When AgentMux launches from Finder / Dock / a mounted DMG as a `.app`,
7//! it inherits launchd's stripped PATH (`/usr/bin:/bin:/usr/sbin:/sbin`).
8//! nvm- and Homebrew-installed `node` / `npm` / `git` live in the user's
9//! *login-shell* PATH, which a GUI launch never sees — so the CLI installer
10//! (`Command::new("npm")`) dies with `npm: command not found`, and spawned
11//! agent CLIs can't find their interpreter either.
12//!
13//! [`resolve_login_path`] reconstructs a usable PATH from the user's login
14//! shell (`$SHELL -lic 'printf … "$PATH"'`, with a hard timeout) unioned with
15//! well-known toolchain directories. It is **purely additive** — inherited
16//! system directories are never dropped — and a no-op on Windows (whose GUI
17//! apps inherit a usable PATH).
18//!
19//! See `docs/specs/SPEC_TOOLCHAIN_MANAGER_2026-06-15.md` §3.
20
21use std::collections::HashSet;
22use std::path::Path;
23
24/// How the effective PATH was produced — surfaced in logs and (later) the
25/// Toolchain modal so PATH problems are diagnosable rather than mysterious.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PathSource {
28    /// The inherited PATH was already sufficient (or we're on Windows); unchanged.
29    Inherited,
30    /// Augmented from the user's login shell (`$SHELL -lic`).
31    LoginShell,
32    /// Login-shell capture failed; augmented from well-known dirs only.
33    FallbackDirs,
34}
35
36impl PathSource {
37    pub fn as_str(self) -> &'static str {
38        match self {
39            PathSource::Inherited => "inherited",
40            PathSource::LoginShell => "login-shell",
41            PathSource::FallbackDirs => "fallback-dirs",
42        }
43    }
44}
45
46/// The enriched PATH plus a record of how it was derived.
47#[derive(Debug, Clone)]
48pub struct EnrichedPath {
49    pub path: String,
50    pub source: PathSource,
51}
52
53/// The launchd-default PATH a Finder/Dock-launched macOS `.app` inherits.
54const LAUNCHD_DEFAULT_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
55
56/// Build a usable PATH for spawning the toolchain (`node`/`npm`/`git`/`docker`
57/// and the provider CLIs). Additive over the inherited PATH; never removes an
58/// inherited entry. On Windows this returns the inherited PATH unchanged.
59pub fn resolve_login_path() -> EnrichedPath {
60    let inherited = std::env::var("PATH").unwrap_or_default();
61
62    #[cfg(not(unix))]
63    {
64        return EnrichedPath {
65            path: inherited,
66            source: PathSource::Inherited,
67        };
68    }
69
70    #[cfg(unix)]
71    {
72        let login_shell = capture_login_shell_path(&resolve_user_shell(), None);
73        build_enriched(&inherited, login_shell.as_deref(), &wellknown_dirs())
74    }
75}
76
77/// True when the current PATH is the stripped launchd default (every entry is
78/// one of `/usr/bin`, `/bin`, `/usr/sbin`, `/sbin`) — i.e. a GUI launch that
79/// never saw the user's shell. Used by the srv to decide whether the cheap
80/// guard should pay for a login-shell spawn on a *direct* launch.
81pub fn looks_like_launchd_default(path: &str) -> bool {
82    let defaults: HashSet<&str> = LAUNCHD_DEFAULT_DIRS.iter().copied().collect();
83    let entries: Vec<&str> = path.split(':').filter(|e| !e.is_empty()).collect();
84    !entries.is_empty() && entries.iter().all(|e| defaults.contains(e))
85}
86
87/// Enrich the *current process'* PATH in place, but only when it looks like the
88/// stripped launchd default. Idempotent and cheap when the PATH is already
89/// healthy (no shell spawn). Returns the source used. Intended for the srv's
90/// direct-launch fallback; the host enriches the srv's PATH unconditionally
91/// when it spawns it (it knows it is the GUI entry point).
92pub fn enrich_current_process_path() -> PathSource {
93    let current = std::env::var("PATH").unwrap_or_default();
94    if !looks_like_launchd_default(&current) {
95        return PathSource::Inherited;
96    }
97    let enriched = resolve_login_path();
98    if enriched.source != PathSource::Inherited {
99        std::env::set_var("PATH", &enriched.path);
100    }
101    enriched.source
102}
103
104/// What [`enrich_current_process_path_cached`] did.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct EnrichOutcome {
107    pub source: PathSource,
108    /// The login-shell PATH came from the cache, not a fresh shell spawn.
109    /// The caller should refresh the cache in the background
110    /// ([`refresh_login_path_cache`]).
111    pub from_cache: bool,
112}
113
114/// Like [`enrich_current_process_path`], but reuses the login-shell PATH
115/// captured by an earlier launch when nothing it depends on has changed:
116/// the shell, the inherited PATH, and the size and mtime of every shell
117/// startup file and `/etc/paths{,.d}`. On a hit no shell is spawned, which
118/// takes the 1-2 s an interactive login shell costs (oh-my-zsh, conda, nvm)
119/// off the boot path. A miss spawns the shell as before and writes the cache.
120///
121/// Startup files can source other files (a plugin, `nvm alias default`) whose
122/// changes the fingerprint can't see, so a hit is only as fresh as the last
123/// launch: run [`refresh_login_path_cache`] after boot so the next launch
124/// picks such a change up.
125pub fn enrich_current_process_path_cached(cache_file: Option<&Path>) -> EnrichOutcome {
126    let current = std::env::var("PATH").unwrap_or_default();
127    if !looks_like_launchd_default(&current) {
128        return EnrichOutcome { source: PathSource::Inherited, from_cache: false };
129    }
130    match cache_file {
131        #[cfg(unix)]
132        Some(cache_file) => enrich_from_cache_or_shell(cache_file, &current),
133        _ => EnrichOutcome { source: enrich_current_process_path(), from_cache: false },
134    }
135}
136
137#[cfg(unix)]
138fn enrich_from_cache_or_shell(cache_file: &Path, current: &str) -> EnrichOutcome {
139    let shell = resolve_user_shell();
140    let fingerprint = login_path_fingerprint(&shell, current);
141    let (login, from_cache) = match cache::read(cache_file, &fingerprint) {
142        Some(login) => (Some(login), true),
143        None => {
144            let login = capture_login_shell_path(&shell, None);
145            if let Some(login) = login.as_deref() {
146                cache::write(cache_file, &fingerprint, login);
147            }
148            (login, false)
149        }
150    };
151    let enriched = build_enriched(current, login.as_deref(), &wellknown_dirs());
152    if enriched.source != PathSource::Inherited {
153        std::env::set_var("PATH", &enriched.path);
154    }
155    EnrichOutcome { source: enriched.source, from_cache }
156}
157
158/// Re-run the login shell and rewrite the cache that
159/// [`enrich_current_process_path_cached`] read. `inherited` is the PATH the
160/// process started with (before enrichment), so the fingerprint matches the
161/// next launch's. Returns the freshly captured enriched PATH when it differs
162/// from `current_enriched`, `None` when nothing changed or the shell
163/// couldn't be read. Never touches this process' environment.
164#[cfg(unix)]
165pub fn refresh_login_path_cache(
166    cache_file: &Path,
167    inherited: &str,
168    current_enriched: &str,
169) -> Option<String> {
170    let shell = resolve_user_shell();
171    let fingerprint = login_path_fingerprint(&shell, inherited);
172    // Start the shell from the PATH this process inherited, as the boot-time
173    // capture did, not the enriched one now in the environment: a startup
174    // file that appends to $PATH would otherwise carry every cached entry
175    // forward, and a directory removed from the user's setup would never
176    // drop out of the cache.
177    let login = capture_login_shell_path(&shell, Some(inherited))?;
178    cache::write(cache_file, &fingerprint, &login);
179    let enriched = build_enriched(inherited, Some(&login), &wellknown_dirs());
180    (enriched.path != current_enriched).then_some(enriched.path)
181}
182
183/// Everything the login shell's PATH can depend on that is cheap to check:
184/// the shell, the PATH it was started with, and the size + mtime of each
185/// startup file bash/zsh read and of `path_helper`'s inputs. A changed or
186/// newly created file changes the fingerprint.
187#[cfg(unix)]
188fn login_path_fingerprint(shell: &str, inherited: &str) -> String {
189    let mut dirs: Vec<std::path::PathBuf> = Vec::new();
190    if let Ok(home) = std::env::var("HOME") {
191        dirs.push(home.into());
192    }
193    if let Ok(zdot) = std::env::var("ZDOTDIR") {
194        dirs.push(zdot.into());
195    }
196    fingerprint_with_dirs(shell, inherited, &dirs)
197}
198
199/// [`login_path_fingerprint`] with the per-user startup-file directories
200/// passed in, so tests don't have to change `HOME`.
201#[cfg(unix)]
202fn fingerprint_with_dirs(shell: &str, inherited: &str, dirs: &[std::path::PathBuf]) -> String {
203    use std::fmt::Write as _;
204
205    let mut files: Vec<std::path::PathBuf> = [
206        "/etc/zshenv", "/etc/zprofile", "/etc/zshrc", "/etc/zlogin",
207        "/etc/profile", "/etc/bashrc", "/etc/paths", "/etc/paths.d",
208    ]
209    .iter()
210    .map(std::path::PathBuf::from)
211    .collect();
212    for dir in dirs {
213        for name in [
214            ".zshenv", ".zprofile", ".zshrc", ".zlogin",
215            ".bash_profile", ".bash_login", ".profile", ".bashrc",
216        ] {
217            files.push(dir.join(name));
218        }
219    }
220    if let Ok(entries) = std::fs::read_dir("/etc/paths.d") {
221        let mut extra: Vec<_> = entries.flatten().map(|e| e.path()).collect();
222        extra.sort();
223        files.extend(extra);
224    }
225
226    let mut fp = format!("shell={shell}\ninherited={inherited}\n");
227    for f in &files {
228        match std::fs::metadata(f) {
229            Ok(m) => {
230                let mtime = m
231                    .modified()
232                    .ok()
233                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
234                    .map(|d| d.as_nanos())
235                    .unwrap_or(0);
236                let _ = writeln!(fp, "{}={}:{}", f.display(), m.len(), mtime);
237            }
238            Err(_) => {
239                let _ = writeln!(fp, "{}=-", f.display());
240            }
241        }
242    }
243    fp
244}
245
246#[cfg(unix)]
247mod cache {
248    use std::path::Path;
249
250    #[derive(serde::Serialize, serde::Deserialize)]
251    struct Entry {
252        fingerprint: String,
253        login_path: String,
254    }
255
256    pub(super) fn read(file: &Path, fingerprint: &str) -> Option<String> {
257        let text = std::fs::read_to_string(file).ok()?;
258        let entry: Entry = serde_json::from_str(&text).ok()?;
259        (entry.fingerprint == fingerprint && !entry.login_path.is_empty())
260            .then_some(entry.login_path)
261    }
262
263    /// Best effort: a cache that can't be written only costs the next launch
264    /// a shell spawn. Written via a temp file + rename so a concurrent reader
265    /// (another channel's srv starting) never sees half a file.
266    pub(super) fn write(file: &Path, fingerprint: &str, login_path: &str) {
267        let Some(dir) = file.parent() else { return };
268        if std::fs::create_dir_all(dir).is_err() {
269            return;
270        }
271        let entry = Entry {
272            fingerprint: fingerprint.to_string(),
273            login_path: login_path.to_string(),
274        };
275        let Ok(json) = serde_json::to_string(&entry) else { return };
276        let tmp = file.with_extension(format!("tmp.{}", std::process::id()));
277        if std::fs::write(&tmp, json).is_ok() && std::fs::rename(&tmp, file).is_err() {
278            let _ = std::fs::remove_file(&tmp);
279        }
280    }
281}
282
283/// The user's login shell, falling back to a per-OS default.
284#[cfg(unix)]
285fn resolve_user_shell() -> String {
286    std::env::var("SHELL")
287        .ok()
288        .filter(|s| !s.is_empty())
289        .unwrap_or_else(|| {
290            if cfg!(target_os = "macos") {
291                "/bin/zsh".to_string()
292            } else {
293                "/bin/bash".to_string()
294            }
295        })
296}
297
298/// Run `<shell> -lic 'printf …"$PATH"'` and return the captured PATH, or `None`
299/// on timeout / failure / empty. A sentinel wraps the value so noise printed by
300/// the user's rc files to stdout can't corrupt the parse. Bounded by a 2s hard
301/// timeout so a slow/hung rc file can never block startup.
302#[cfg(unix)]
303fn capture_login_shell_path(shell: &str, path: Option<&str>) -> Option<String> {
304    use std::io::Read;
305    use std::process::{Command, Stdio};
306    use std::sync::mpsc;
307    use std::time::Duration;
308
309    // `printf` without a trailing newline; markers isolate our value from any
310    // banner the rc files emit to stdout.
311    let script = r#"printf '__AMUX_PATH__%s__AMUX_END__' "$PATH""#;
312
313    let mut cmd = Command::new(shell);
314    if let Some(path) = path {
315        cmd.env("PATH", path);
316    }
317    let mut child = cmd
318        .args(["-lic", script])
319        .stdin(Stdio::null())
320        .stdout(Stdio::piped())
321        .stderr(Stdio::null())
322        .spawn()
323        .ok()?;
324
325    let mut stdout = child.stdout.take()?;
326    // Read on a worker thread so a hung child can't block us past the timeout.
327    let (tx, rx) = mpsc::channel();
328    std::thread::spawn(move || {
329        let mut buf = String::new();
330        let _ = stdout.read_to_string(&mut buf);
331        let _ = tx.send(buf);
332    });
333
334    let buf = match rx.recv_timeout(Duration::from_secs(2)) {
335        Ok(b) => {
336            let _ = child.wait();
337            b
338        }
339        Err(_) => {
340            // Timed out — kill the child; the reader thread unblocks when the
341            // pipe closes and exits on its own (its send is discarded).
342            let _ = child.kill();
343            let _ = child.wait();
344            return None;
345        }
346    };
347
348    parse_sentinel(&buf)
349}
350
351/// Extract the PATH wrapped between the sentinels, ignoring surrounding noise.
352#[cfg(any(unix, test))]
353fn parse_sentinel(buf: &str) -> Option<String> {
354    const START: &str = "__AMUX_PATH__";
355    const END: &str = "__AMUX_END__";
356    let s = buf.find(START)? + START.len();
357    let e = buf[s..].find(END)? + s;
358    let path = &buf[s..e];
359    if path.is_empty() {
360        None
361    } else {
362        Some(path.to_string())
363    }
364}
365
366/// Well-known toolchain directories, existence-checked and de-duplicated. An
367/// absent directory costs nothing (filtered out), so the list can be generous.
368#[cfg(unix)]
369fn wellknown_dirs() -> Vec<String> {
370    let home = std::env::var("HOME").unwrap_or_default();
371    let mut candidates: Vec<String> = Vec::new();
372
373    if !home.is_empty() {
374        if let Some(bin) = nvm_current_bin(&home) {
375            candidates.push(bin);
376        }
377    }
378    candidates.push("/opt/homebrew/bin".to_string());
379    candidates.push("/opt/homebrew/sbin".to_string());
380    candidates.push("/usr/local/bin".to_string());
381    if !home.is_empty() {
382        candidates.push(format!("{home}/.local/bin"));
383        candidates.push(format!("{home}/.cargo/bin"));
384    }
385    candidates.push("/opt/local/bin".to_string());
386
387    #[cfg(target_os = "linux")]
388    {
389        candidates.push("/snap/bin".to_string());
390        candidates.push("/var/lib/flatpak/exports/bin".to_string());
391        if !home.is_empty() {
392            candidates.push(format!("{home}/.local/share/flatpak/exports/bin"));
393        }
394    }
395
396    candidates
397        .into_iter()
398        .filter(|d| Path::new(d).is_dir())
399        .collect()
400}
401
402/// Resolve the `bin` dir of the nvm "current/default" Node, if nvm is present.
403/// Prefers the `default` alias; falls back to the highest installed version.
404#[cfg(unix)]
405fn nvm_current_bin(home: &str) -> Option<String> {
406    let versions_dir = format!("{home}/.nvm/versions/node");
407    if !Path::new(&versions_dir).is_dir() {
408        return None;
409    }
410
411    // Prefer the `default` alias (e.g. "v20.18.0", "20", "lts/*", "node").
412    if let Ok(alias) = std::fs::read_to_string(format!("{home}/.nvm/alias/default")) {
413        let a = alias.trim();
414        if !a.is_empty() {
415            let v = if a.starts_with('v') {
416                a.to_string()
417            } else {
418                format!("v{a}")
419            };
420            let bin = format!("{versions_dir}/{v}/bin");
421            if Path::new(&bin).is_dir() {
422                return Some(bin);
423            }
424        }
425    }
426
427    // Fall back to the highest installed version (numeric, not lexical, so
428    // v10 > v9).
429    let mut versions: Vec<(u64, u64, u64, String)> = std::fs::read_dir(&versions_dir)
430        .ok()?
431        .filter_map(|e| e.ok())
432        .filter_map(|e| e.file_name().into_string().ok())
433        .filter(|n| n.starts_with('v'))
434        .map(|n| {
435            let (a, b, c) = parse_semverish(&n);
436            (a, b, c, n)
437        })
438        .collect();
439    versions.sort();
440    versions
441        .last()
442        .map(|(_, _, _, n)| format!("{versions_dir}/{n}/bin"))
443}
444
445/// Parse a leading "vMAJOR.MINOR.PATCH" into a comparable tuple. Missing or
446/// non-numeric parts sort as 0.
447#[cfg(any(unix, test))]
448fn parse_semverish(v: &str) -> (u64, u64, u64) {
449    let core = v.strip_prefix('v').unwrap_or(v);
450    let mut it = core.split('.');
451    let p = |o: Option<&str>| o.and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
452    (p(it.next()), p(it.next()), p(it.next()))
453}
454
455/// Pure merge: priority order is login-shell entries, then well-known dirs,
456/// then the inherited PATH — de-duplicated, first occurrence wins. Inherited
457/// entries are always preserved (appended), so the result is a superset.
458#[cfg(any(unix, test))]
459fn build_enriched(inherited: &str, login_shell: Option<&str>, wellknown: &[String]) -> EnrichedPath {
460    let mut seen: HashSet<&str> = HashSet::new();
461    let mut out: Vec<&str> = Vec::new();
462    let used_login = login_shell.is_some();
463
464    if let Some(ls) = login_shell {
465        for e in ls.split(':') {
466            if !e.is_empty() && seen.insert(e) {
467                out.push(e);
468            }
469        }
470    }
471
472    let before = out.len();
473    for d in wellknown {
474        if !d.is_empty() && seen.insert(d.as_str()) {
475            out.push(d.as_str());
476        }
477    }
478    let wellknown_added = out.len() > before;
479
480    for e in inherited.split(':') {
481        if !e.is_empty() && seen.insert(e) {
482            out.push(e);
483        }
484    }
485
486    let path = out.join(":");
487    // Did we actually change anything vs. just the inherited set?
488    let changed = path != normalize(inherited);
489    let source = if !changed {
490        PathSource::Inherited
491    } else if used_login {
492        PathSource::LoginShell
493    } else if wellknown_added {
494        PathSource::FallbackDirs
495    } else {
496        PathSource::Inherited
497    };
498
499    EnrichedPath { path, source }
500}
501
502/// De-dup + drop empties, preserving order — for comparing "did we change it".
503#[cfg(any(unix, test))]
504fn normalize(path: &str) -> String {
505    let mut seen = HashSet::new();
506    path.split(':')
507        .filter(|e| !e.is_empty() && seen.insert(*e))
508        .collect::<Vec<_>>()
509        .join(":")
510}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn parse_sentinel_ignores_rc_noise() {
518        let buf = "welcome to my shell!\nsome banner\n__AMUX_PATH__/opt/homebrew/bin:/usr/bin__AMUX_END__";
519        assert_eq!(
520            parse_sentinel(buf).as_deref(),
521            Some("/opt/homebrew/bin:/usr/bin")
522        );
523    }
524
525    #[test]
526    fn parse_sentinel_none_when_missing_or_empty() {
527        assert_eq!(parse_sentinel("no markers here"), None);
528        assert_eq!(parse_sentinel("__AMUX_PATH____AMUX_END__"), None);
529    }
530
531    #[test]
532    fn launchd_default_detection() {
533        assert!(looks_like_launchd_default("/usr/bin:/bin:/usr/sbin:/sbin"));
534        assert!(looks_like_launchd_default("/bin:/usr/bin"));
535        assert!(!looks_like_launchd_default(
536            "/opt/homebrew/bin:/usr/bin:/bin"
537        ));
538        assert!(!looks_like_launchd_default(""));
539    }
540
541    #[test]
542    fn semverish_sorts_numerically() {
543        assert!(parse_semverish("v10.0.0") > parse_semverish("v9.99.99"));
544        assert_eq!(parse_semverish("v20.18.1"), (20, 18, 1));
545        assert_eq!(parse_semverish("vbogus"), (0, 0, 0));
546    }
547
548    #[test]
549    fn build_prepends_login_shell_and_preserves_inherited() {
550        let e = build_enriched(
551            "/usr/bin:/bin",
552            Some("/opt/homebrew/bin:/usr/bin:/bin"),
553            &[],
554        );
555        // login-shell entry comes first; inherited /bin still present once.
556        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
557        assert_eq!(e.source, PathSource::LoginShell);
558    }
559
560    #[test]
561    fn build_falls_back_to_wellknown_when_no_login_shell() {
562        let e = build_enriched(
563            "/usr/bin:/bin",
564            None,
565            &["/opt/homebrew/bin".to_string()],
566        );
567        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
568        assert_eq!(e.source, PathSource::FallbackDirs);
569    }
570
571    #[test]
572    fn build_reports_inherited_when_nothing_added() {
573        // login shell returns exactly the inherited set (e.g. launched from a
574        // terminal that already had the system PATH and nothing else).
575        let e = build_enriched("/usr/bin:/bin", Some("/usr/bin:/bin"), &[]);
576        assert_eq!(e.path, "/usr/bin:/bin");
577        assert_eq!(e.source, PathSource::Inherited);
578    }
579
580    #[test]
581    fn build_dedups_across_all_sources() {
582        let e = build_enriched(
583            "/usr/bin:/bin:/usr/bin",
584            Some("/opt/homebrew/bin:/usr/bin"),
585            &["/opt/homebrew/bin".to_string(), "/usr/local/bin".to_string()],
586        );
587        assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin");
588    }
589
590    #[cfg(unix)]
591    #[test]
592    fn capture_via_real_sh_roundtrips_path() {
593        // /bin/sh honors -c; -lic is accepted (login+interactive flags are
594        // tolerated). The script echoes $PATH between the sentinels.
595        let got = capture_login_shell_path("/bin/sh", None);
596        // On any unix dev/CI box /bin/sh exists and $PATH is non-empty.
597        assert!(got.is_some(), "expected a captured PATH from /bin/sh");
598        assert!(!got.unwrap().is_empty());
599    }
600
601    #[cfg(unix)]
602    #[test]
603    fn login_path_cache_hits_only_on_the_same_fingerprint() {
604        let dir = tempfile::tempdir().unwrap();
605        let file = dir.path().join("cache").join("login-path.json");
606        assert_eq!(cache::read(&file, "fp-a"), None, "no file yet");
607
608        cache::write(&file, "fp-a", "/opt/homebrew/bin:/usr/bin");
609        assert_eq!(
610            cache::read(&file, "fp-a").as_deref(),
611            Some("/opt/homebrew/bin:/usr/bin")
612        );
613        assert_eq!(cache::read(&file, "fp-b"), None, "another fingerprint is a miss");
614
615        std::fs::write(&file, "not json").unwrap();
616        assert_eq!(cache::read(&file, "fp-a"), None, "a corrupt file is a miss");
617    }
618
619    #[cfg(unix)]
620    #[test]
621    fn fingerprint_changes_when_a_startup_file_changes() {
622        let home = tempfile::tempdir().unwrap();
623        let dirs = vec![home.path().to_path_buf()];
624        let base = fingerprint_with_dirs("/bin/zsh", "/usr/bin:/bin", &dirs);
625        assert_eq!(base, fingerprint_with_dirs("/bin/zsh", "/usr/bin:/bin", &dirs), "stable");
626
627        std::fs::write(home.path().join(".zshrc"), "export PATH=/x:$PATH\n").unwrap();
628        let created = fingerprint_with_dirs("/bin/zsh", "/usr/bin:/bin", &dirs);
629        assert_ne!(base, created, "a new startup file must miss the cache");
630
631        std::fs::write(home.path().join(".zshrc"), "export PATH=/longer/x:$PATH\n").unwrap();
632        assert_ne!(
633            created,
634            fingerprint_with_dirs("/bin/zsh", "/usr/bin:/bin", &dirs),
635            "an edited startup file must miss the cache"
636        );
637
638        assert_ne!(base, fingerprint_with_dirs("/bin/bash", "/usr/bin:/bin", &dirs), "shell");
639        assert_ne!(base, fingerprint_with_dirs("/bin/zsh", "/usr/bin", &dirs), "inherited PATH");
640    }
641}