Skip to main content

aft/
effective_path.rs

1//! Process-wide PATH enrichment for children spawned by AFT.
2//!
3//! Daemon launches can inherit a system-only PATH that misses the user's package
4//! managers and version-manager shims. AFT initializes this module before any
5//! helper threads start so later subprocesses inherit the same PATH a login
6//! terminal would provide.
7
8use std::ffi::{OsStr, OsString};
9use std::sync::OnceLock;
10
11#[cfg(unix)]
12use std::collections::HashSet;
13#[cfg(unix)]
14use std::path::{Path, PathBuf};
15#[cfg(unix)]
16use std::process::{Command, Stdio};
17#[cfg(unix)]
18use std::time::{Duration, Instant};
19
20#[cfg(unix)]
21use std::os::unix::ffi::{OsStrExt, OsStringExt};
22#[cfg(unix)]
23use std::os::unix::io::AsRawFd;
24#[cfg(unix)]
25use std::os::unix::process::CommandExt;
26
27#[cfg(unix)]
28use serde::{Deserialize, Serialize};
29
30#[cfg(not(unix))]
31static EFFECTIVE_PATH: OnceLock<OsString> = OnceLock::new();
32
33#[cfg(unix)]
34#[derive(Clone, Debug)]
35struct PathState {
36    path: &'static OsStr,
37    is_fallback: bool,
38    last_probe_attempt: Option<Instant>,
39}
40
41#[cfg(unix)]
42static EFFECTIVE_PATH_STATE: std::sync::Mutex<Option<PathState>> = std::sync::Mutex::new(None);
43
44#[cfg(unix)]
45const LOGIN_SHELL_PATH_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
46
47/// Compute and export AFT's process PATH.
48///
49/// Call this during process startup, before AFT starts worker threads or async
50/// executors. Mutating process environment variables while other threads may be
51/// reading them is not safe on Unix, so later code should read the cached value
52/// with [`effective_path`] instead of calling this initializer again.
53pub fn initialize_process_path() -> &'static OsStr {
54    let path = effective_path();
55
56    #[cfg(unix)]
57    {
58        if path != OsStr::new("") && std::env::var_os("PATH").as_deref() != Some(path) {
59            std::env::set_var("PATH", path);
60        }
61    }
62
63    path
64}
65
66/// Create a new `Command` with the effective PATH set on Unix.
67#[cfg(unix)]
68pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
69    let mut cmd = std::process::Command::new(program);
70    cmd.env("PATH", effective_path());
71    cmd
72}
73
74/// On Windows the process PATH is already correct (registry-backed
75/// environment block); pass through without touching the child env.
76#[cfg(not(unix))]
77pub fn new_command<S: AsRef<OsStr>>(program: S) -> std::process::Command {
78    std::process::Command::new(program)
79}
80
81/// Return the cached PATH that subprocesses should inherit.
82///
83/// On Windows this is the process PATH unchanged: Windows daemon environments
84/// already receive PATH from the registry-backed environment block.
85#[cfg(not(unix))]
86pub fn effective_path() -> &'static OsStr {
87    EFFECTIVE_PATH
88        .get_or_init(compute_effective_path)
89        .as_os_str()
90}
91
92#[cfg(unix)]
93pub fn effective_path() -> &'static OsStr {
94    // Test seam: integration tests construct exact PATHs (e.g. to simulate a
95    // missing formatter binary); probing and enrichment would re-add real tool
96    // dirs from the host and break that isolation. Checked at runtime because
97    // the spawned test binary is a production build.
98    // "0" reads as unset so the PATH feature's own integration tests can
99    // opt back in to probing under a test harness that defaults the seam on.
100    if std::env::var_os("AFT_TEST_RAW_PATH").is_some_and(|v| v != "0" && !v.is_empty()) {
101        static RAW: OnceLock<OsString> = OnceLock::new();
102        return RAW
103            .get_or_init(|| std::env::var_os("PATH").unwrap_or_default())
104            .as_os_str();
105    }
106    let mut guard = EFFECTIVE_PATH_STATE
107        .lock()
108        .unwrap_or_else(|e| e.into_inner());
109    let state_opt = guard.clone();
110    if let Some(state) = state_opt {
111        if state.is_fallback {
112            let now = Instant::now();
113            let should_retry = state
114                .last_probe_attempt
115                .map(|last| now.duration_since(last) >= Duration::from_secs(60))
116                .unwrap_or(true);
117
118            if should_retry {
119                let last_attempt = Some(now);
120                let mut temp_state = state.clone();
121                temp_state.last_probe_attempt = last_attempt;
122                *guard = Some(temp_state);
123                drop(guard);
124
125                let new_path_opt = probe_login_shell_path();
126
127                let mut guard = EFFECTIVE_PATH_STATE
128                    .lock()
129                    .unwrap_or_else(|e| e.into_inner());
130                if let Some(new_path) = new_path_opt {
131                    write_login_path_memo(&new_path);
132                    let current = std::env::var_os("PATH").unwrap_or_default();
133                    let merged = merge_current_and_login_path(&current, &new_path);
134                    let home = std::env::var_os("HOME");
135                    let enriched =
136                        append_missing_standard_dirs(&merged, home.as_deref(), |dir| dir.is_dir());
137                    let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
138                    let new_state = PathState {
139                        path: leaked,
140                        is_fallback: false,
141                        last_probe_attempt: None,
142                    };
143                    *guard = Some(new_state);
144                    return leaked;
145                } else {
146                    let memo_path_opt = read_login_path_memo();
147                    if let Some(mut current_state) = guard.clone() {
148                        current_state.last_probe_attempt = Some(Instant::now());
149                        if let Some(memo_path) = memo_path_opt {
150                            let current = std::env::var_os("PATH").unwrap_or_default();
151                            let merged = merge_current_and_login_path(&current, &memo_path);
152                            let home = std::env::var_os("HOME");
153                            let enriched =
154                                append_missing_standard_dirs(&merged, home.as_deref(), |dir| {
155                                    dir.is_dir()
156                                });
157                            let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
158                            current_state.path = leaked;
159                            current_state.is_fallback = false;
160                        }
161                        *guard = Some(current_state);
162                        return guard.as_ref().unwrap().path;
163                    }
164                }
165            }
166        }
167        return state.path;
168    }
169
170    let current = std::env::var_os("PATH").unwrap_or_default();
171    let home = std::env::var_os("HOME");
172
173    let mut is_fallback = false;
174    let mut last_probe_attempt = None;
175
176    // Probe every startup: a daemon PATH can contain the usual system and
177    // package-manager directories while still omitting entries added by an
178    // interactive shell rc file.
179    let path = if let Some(login_path) = probe_login_shell_path() {
180        write_login_path_memo(&login_path);
181        merge_current_and_login_path(&current, &login_path)
182    } else if let Some(memo_path) = read_login_path_memo() {
183        merge_current_and_login_path(&current, &memo_path)
184    } else {
185        is_fallback = true;
186        last_probe_attempt = Some(Instant::now());
187        current.to_os_string()
188    };
189
190    let enriched = append_missing_standard_dirs(&path, home.as_deref(), |dir| dir.is_dir());
191    let leaked: &'static OsStr = Box::leak(enriched.into_boxed_os_str());
192    *guard = Some(PathState {
193        path: leaked,
194        is_fallback,
195        last_probe_attempt,
196    });
197    leaked
198}
199
200#[cfg(windows)]
201fn compute_effective_path() -> OsString {
202    std::env::var_os("PATH").unwrap_or_default()
203}
204
205#[cfg(not(any(unix, windows)))]
206fn compute_effective_path() -> OsString {
207    std::env::var_os("PATH").unwrap_or_default()
208}
209
210/// Core tool directories are kept first in the standard-directory fallback.
211#[cfg(unix)]
212fn core_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
213    let mut dirs = vec![
214        PathBuf::from("/opt/homebrew/bin"),
215        PathBuf::from("/usr/local/bin"),
216    ];
217    if let Some(home) = home {
218        let home = PathBuf::from(home);
219        dirs.push(home.join(".cargo/bin"));
220        dirs.push(home.join(".local/bin"));
221    }
222    dirs
223}
224
225/// All dirs merged into every constructed PATH when present on disk. Includes
226/// common installer locations that may not be represented in a shell probe.
227#[cfg(unix)]
228fn user_standard_path_dirs(home: Option<&OsStr>) -> Vec<PathBuf> {
229    let mut dirs = core_standard_path_dirs(home);
230    if let Some(home) = home {
231        let home = PathBuf::from(home);
232        dirs.push(home.join(".bun/bin"));
233        dirs.push(home.join("Library/pnpm"));
234        dirs.push(home.join(".local/share/pnpm"));
235        dirs.push(home.join(".local/share/mise/shims"));
236        dirs.push(home.join(".deno/bin"));
237        dirs.push(home.join(".volta/bin"));
238    }
239    dirs
240}
241
242#[cfg(unix)]
243fn append_missing_standard_dirs<D>(
244    path: &OsStr,
245    home: Option<&OsStr>,
246    mut dir_exists: D,
247) -> OsString
248where
249    D: FnMut(&Path) -> bool,
250{
251    let mut entries: Vec<PathBuf> = std::env::split_paths(path).collect();
252    let mut seen: HashSet<PathBuf> = entries.iter().cloned().collect();
253
254    for dir in user_standard_path_dirs(home) {
255        if dir_exists(&dir) && seen.insert(dir.clone()) {
256            entries.push(dir);
257        }
258    }
259
260    std::env::join_paths(entries).unwrap_or_else(|_| path.to_os_string())
261}
262
263#[cfg(unix)]
264fn probe_login_shell_path() -> Option<OsString> {
265    let candidates = login_shell_candidates();
266    for shell in candidates {
267        let Some(path) = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT) else {
268            continue;
269        };
270        if login_path_is_acceptable(&path) {
271            return Some(path);
272        }
273    }
274    None
275}
276
277#[cfg(unix)]
278fn login_shell_candidates() -> Vec<PathBuf> {
279    if cfg!(test) {
280        if let Some(val) = std::env::var_os("AFT_TEST_LOGIN_SHELL_CANDIDATES") {
281            return std::env::split_paths(&val).collect();
282        }
283    }
284    let mut candidates = Vec::new();
285    if let Some(shell) = std::env::var_os("SHELL").filter(|value| !value.is_empty()) {
286        candidates.push(PathBuf::from(shell));
287    }
288    let zsh = PathBuf::from("/bin/zsh");
289    let bash = PathBuf::from("/bin/bash");
290    if !candidates.contains(&zsh) {
291        candidates.push(zsh);
292    }
293    if !candidates.contains(&bash) {
294        candidates.push(bash);
295    }
296    candidates
297}
298
299#[cfg(unix)]
300fn set_nonblocking<F: AsRawFd>(file: &F) -> std::io::Result<()> {
301    let fd = file.as_raw_fd();
302    unsafe {
303        let flags = libc::fcntl(fd, libc::F_GETFL);
304        if flags < 0 {
305            return Err(std::io::Error::last_os_error());
306        }
307        if libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
308            return Err(std::io::Error::last_os_error());
309        }
310    }
311    Ok(())
312}
313
314#[cfg(unix)]
315fn probe_login_shell_path_once(shell: &Path, timeout: Duration) -> Option<OsString> {
316    let mut command = Command::new(shell);
317
318    command
319        .arg(probe_shell_flags(shell))
320        .arg(probe_shell_command(shell))
321        .stdin(Stdio::null())
322        .stdout(Stdio::piped())
323        .stderr(Stdio::null());
324    // Run the probe in its own session so the timeout can kill login-shell
325    // startup helpers as well as the shell process itself.
326    unsafe {
327        command.pre_exec(|| {
328            if libc::setsid() == -1 {
329                return Err(std::io::Error::last_os_error());
330            }
331            Ok(())
332        });
333    }
334    let mut child = command.spawn().ok()?;
335    let mut stdout = child.stdout.take()?;
336    let _ = set_nonblocking(&stdout);
337    let mut output_bytes = Vec::new();
338    let mut buf = [0u8; 1024];
339
340    let deadline = Instant::now() + timeout;
341    loop {
342        use std::io::Read;
343        match stdout.read(&mut buf) {
344            Ok(0) => {
345                let wait_deadline = Instant::now() + Duration::from_secs(1);
346                loop {
347                    match child.try_wait() {
348                        Ok(Some(_)) => break,
349                        Ok(None) if Instant::now() >= wait_deadline => {
350                            kill_login_shell_probe(&mut child);
351                            break;
352                        }
353                        Ok(None) => {
354                            std::thread::sleep(Duration::from_millis(10));
355                        }
356                        Err(_) => {
357                            kill_login_shell_probe(&mut child);
358                            break;
359                        }
360                    }
361                }
362                return extract_probe_path(&output_bytes);
363            }
364            Ok(n) => {
365                output_bytes.extend_from_slice(&buf[..n]);
366            }
367            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
368                // No data available right now
369            }
370            Err(_) => {
371                kill_login_shell_probe(&mut child);
372                return None;
373            }
374        }
375
376        match child.try_wait() {
377            Ok(Some(_)) => {
378                loop {
379                    match stdout.read(&mut buf) {
380                        Ok(0) => break,
381                        Ok(n) => output_bytes.extend_from_slice(&buf[..n]),
382                        Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
383                            break;
384                        }
385                        Err(_) => break,
386                    }
387                }
388                let _ = child.wait();
389                return extract_probe_path(&output_bytes);
390            }
391            Ok(None) if Instant::now() >= deadline => {
392                kill_login_shell_probe(&mut child);
393                return None;
394            }
395            Ok(None) => {
396                std::thread::sleep(Duration::from_millis(25));
397            }
398            Err(_) => {
399                kill_login_shell_probe(&mut child);
400                return None;
401            }
402        }
403    }
404}
405
406#[cfg(unix)]
407fn probe_shell_flags(shell: &Path) -> &'static str {
408    let name = shell.file_name().and_then(|name| name.to_str());
409    if name.is_some_and(|name| name.eq_ignore_ascii_case("zsh")) {
410        // zsh login shells read .zprofile/.zlogin, while interactive shells
411        // read .zshrc; both modes are needed to reproduce a terminal PATH.
412        "-lic"
413    } else {
414        // bash -lc reads the login startup files (which conventionally source
415        // .bashrc), and fish -lc reads config.fish without needing -i.
416        "-lc"
417    }
418}
419
420#[cfg(unix)]
421fn probe_shell_command(shell: &Path) -> &'static str {
422    let name = shell.file_name().and_then(|name| name.to_str());
423    if name.is_some_and(|name| name.eq_ignore_ascii_case("fish")) {
424        r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' (string join : $PATH)"#
425    } else {
426        r#"printf '\n__AFT_PATH_BEGIN__%s__AFT_PATH_END__\n' "$PATH""#
427    }
428}
429
430#[cfg(unix)]
431fn extract_probe_path(output: &[u8]) -> Option<OsString> {
432    const BEGIN: &[u8] = b"__AFT_PATH_BEGIN__";
433    const END: &[u8] = b"__AFT_PATH_END__";
434    let begin = output
435        .windows(BEGIN.len())
436        .position(|window| window == BEGIN)?
437        + BEGIN.len();
438    let end = output[begin..]
439        .windows(END.len())
440        .position(|window| window == END)?
441        + begin;
442    Some(OsString::from_vec(output[begin..end].to_vec()))
443}
444
445#[cfg(unix)]
446fn kill_login_shell_probe(child: &mut std::process::Child) {
447    let pid = child.id() as i32;
448    if pid > 0 {
449        // Negative PID targets the process group created by setsid above.
450        unsafe {
451            let _ = libc::kill(-pid, libc::SIGKILL);
452        }
453    }
454    let _ = child.kill();
455    let _ = child.wait();
456}
457
458#[cfg(unix)]
459fn login_path_is_acceptable(path: &OsStr) -> bool {
460    let bytes = path.as_bytes();
461    if bytes.is_empty()
462        || bytes
463            .iter()
464            .any(|byte| matches!(byte, b'\0' | b'\n' | b'\r'))
465    {
466        return false;
467    }
468
469    if bytes.contains(&b' ') {
470        let mut abs_count = 0;
471        for part in bytes.split(|&b| b == b' ') {
472            if part.first() == Some(&b'/') {
473                abs_count += 1;
474            }
475        }
476        if abs_count > 1 {
477            return false;
478        }
479    }
480
481    bytes
482        .split(|byte| *byte == b':')
483        .all(|entry| entry.first() == Some(&b'/'))
484}
485
486#[cfg(unix)]
487fn merge_current_and_login_path(current_path: &OsStr, login_path: &OsStr) -> OsString {
488    let mut seen = HashSet::new();
489    let mut merged = Vec::new();
490
491    // Keep daemon-provided ordering and precedence; shell startup files only
492    // contribute entries that are not already present, appended at the end.
493    for entry in std::env::split_paths(current_path).chain(std::env::split_paths(login_path)) {
494        if seen.insert(entry.clone()) {
495            merged.push(entry);
496        }
497    }
498
499    std::env::join_paths(merged).unwrap_or_else(|_| current_path.to_os_string())
500}
501
502#[cfg(unix)]
503#[derive(Serialize, Deserialize)]
504struct LoginPathMemo {
505    login_path: String,
506}
507
508#[cfg(unix)]
509fn read_login_path_memo() -> Option<OsString> {
510    let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
511    let content = std::fs::read_to_string(&memo_path).ok()?;
512    let memo: LoginPathMemo = serde_json::from_str(&content).ok()?;
513    Some(OsString::from(memo.login_path))
514}
515
516#[cfg(unix)]
517fn write_login_path_memo(path: &OsStr) {
518    let memo_path = crate::bash_background::storage_dir(None).join("login-path-memo.json");
519    if let Some(parent) = memo_path.parent() {
520        let _ = std::fs::create_dir_all(parent);
521    }
522    let memo = LoginPathMemo {
523        login_path: path.to_string_lossy().into_owned(),
524    };
525    if let Ok(content) = serde_json::to_string(&memo) {
526        let _ = std::fs::write(&memo_path, content);
527    }
528}
529
530#[cfg(test)]
531#[cfg(unix)]
532mod tests {
533    use super::*;
534    use std::fs;
535    use std::os::unix::fs::PermissionsExt;
536
537    struct EnvVarGuard {
538        key: &'static str,
539        old_value: Option<OsString>,
540    }
541
542    impl EnvVarGuard {
543        fn set(key: &'static str, value: &str) -> Self {
544            let old_value = std::env::var_os(key);
545            std::env::set_var(key, value);
546            Self { key, old_value }
547        }
548    }
549
550    impl Drop for EnvVarGuard {
551        fn drop(&mut self) {
552            if let Some(val) = &self.old_value {
553                std::env::set_var(self.key, val);
554            } else {
555                std::env::remove_var(self.key);
556            }
557        }
558    }
559
560    fn write_executable_shim(path: &Path, body: &str) {
561        fs::write(path, body).unwrap();
562        let mut permissions = fs::metadata(path).unwrap().permissions();
563        permissions.set_mode(0o755);
564        fs::set_permissions(path, permissions).unwrap();
565    }
566
567    #[test]
568    fn probe_entries_are_appended_after_current_entries_without_duplicates() {
569        let current = OsStr::new("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
570        let login = OsString::from("/usr/bin:/custom/bin:/opt/homebrew/bin");
571
572        let effective = merge_current_and_login_path(current, &login);
573
574        assert_eq!(
575            effective,
576            OsString::from("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/custom/bin")
577        );
578    }
579
580    #[test]
581    fn probe_shell_flags_match_shell_startup_conventions() {
582        assert_eq!(probe_shell_flags(Path::new("/bin/zsh")), "-lic");
583        assert_eq!(probe_shell_flags(Path::new("/bin/bash")), "-lc");
584        assert_eq!(
585            probe_shell_flags(Path::new("/opt/homebrew/bin/fish")),
586            "-lc"
587        );
588    }
589
590    #[test]
591    fn marker_extraction_ignores_shell_startup_noise() {
592        let output = b"banner before\n__AFT_PATH_BEGIN__/custom/bin:/usr/bin__AFT_PATH_END__\nbanner after\n";
593
594        assert_eq!(
595            extract_probe_path(output),
596            Some(OsString::from("/custom/bin:/usr/bin"))
597        );
598    }
599
600    #[test]
601    fn zsh_probe_uses_interactive_login_and_reads_zshrc() {
602        let _guard = crate::test_env::process_env_lock();
603        let dir = tempfile::tempdir().unwrap();
604        let home = dir.path().join("home");
605        let custom_bin = home.join(".custom/bin");
606        let shell = dir.path().join("zsh");
607        fs::create_dir_all(&custom_bin).unwrap();
608        fs::write(
609            home.join(".zshrc"),
610            format!(
611                "printf 'banner before\n'; export PATH=\"$PATH:{}\"; printf 'banner after\n'\n",
612                custom_bin.display()
613            ),
614        )
615        .unwrap();
616        write_executable_shim(
617            &shell,
618            r#"#!/bin/sh
619if [ "$1" != '-lic' ]; then
620  exit 64
621fi
622if [ -f "$ZDOTDIR/.zshrc" ]; then
623  . "$ZDOTDIR/.zshrc"
624fi
625eval "$2"
626"#,
627        );
628
629        let _path_guard = EnvVarGuard::set("PATH", "/usr/bin:/bin");
630        let _home_guard = EnvVarGuard::set("HOME", home.to_str().unwrap());
631        let _zdotdir_guard = EnvVarGuard::set("ZDOTDIR", home.to_str().unwrap());
632        let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
633
634        assert_eq!(
635            probed,
636            Some(OsString::from(format!(
637                "/usr/bin:/bin:{}",
638                custom_bin.display()
639            )))
640        );
641    }
642
643    #[test]
644    fn invalid_probe_paths_are_rejected() {
645        let rejected = vec![
646            OsString::new(),
647            OsString::from("/fake/login/bin\n/usr/bin"),
648            OsString::from("/fake/login/bin:relative/bin"),
649            OsString::from_vec(b"/fake/login/bin\0/usr/bin".to_vec()),
650            OsString::from("/usr/bin /bin /opt/homebrew/bin"), // fish-shaped space-joined
651        ];
652
653        for probe_path in rejected {
654            assert!(!login_path_is_acceptable(&probe_path));
655        }
656    }
657
658    #[test]
659    fn login_shell_probe_times_out() {
660        let dir = tempfile::tempdir().expect("create tempdir");
661        let shell = dir.path().join("slow-login-shell");
662        fs::write(
663            &shell,
664            "#!/bin/sh\nsleep 10\nprintf '%s' '/fake/login/bin:/usr/bin:/bin'\n",
665        )
666        .expect("write fake shell");
667        let mut permissions = fs::metadata(&shell)
668            .expect("fake shell metadata")
669            .permissions();
670        permissions.set_mode(0o755);
671        fs::set_permissions(&shell, permissions).expect("chmod fake shell");
672
673        let started = Instant::now();
674        let probed = probe_login_shell_path_once(&shell, LOGIN_SHELL_PATH_PROBE_TIMEOUT);
675
676        assert!(probed.is_none());
677        assert!(
678            started.elapsed() < Duration::from_secs(5),
679            "login-shell PATH probe exceeded the 5s test budget"
680        );
681    }
682
683    #[test]
684    fn test_login_shell_candidates_includes_fallbacks() {
685        let _guard = crate::test_env::process_env_lock();
686        let _shell_guard = EnvVarGuard::set("SHELL", "/opt/zerobrew/bin/fish");
687        let candidates = login_shell_candidates();
688        assert_eq!(candidates[0], PathBuf::from("/opt/zerobrew/bin/fish"));
689        assert!(candidates.contains(&PathBuf::from("/bin/zsh")));
690        assert!(candidates.contains(&PathBuf::from("/bin/bash")));
691    }
692
693    #[test]
694    fn test_memo_written_and_read() {
695        let _guard = crate::test_env::process_env_lock();
696        let temp = tempfile::tempdir().unwrap();
697        let _cache_guard = EnvVarGuard::set("AFT_CACHE_DIR", temp.path().to_str().unwrap());
698
699        let test_path = OsStr::new("/opt/homebrew/bin:/usr/bin:/bin");
700        write_login_path_memo(test_path);
701
702        let read_path = read_login_path_memo().unwrap();
703        assert_eq!(read_path, test_path);
704    }
705
706    #[test]
707    fn probe_failure_falls_back_to_current_path_and_appends_standard_dirs() {
708        let home = Path::new("/home/alice");
709        let current = OsStr::new("/usr/bin:/bin");
710        let missing_shell = Path::new("/nonexistent/shell");
711
712        assert!(probe_login_shell_path_once(missing_shell, Duration::from_millis(10)).is_none());
713
714        let enriched = append_missing_standard_dirs(current, Some(home.as_os_str()), |dir| {
715            dir == Path::new("/home/alice/.bun/bin")
716        });
717        assert_eq!(
718            enriched,
719            OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
720        );
721    }
722
723    #[test]
724    fn test_append_missing_standard_dirs() {
725        let home = OsStr::new("/home/alice");
726        let path = OsStr::new("/usr/bin:/bin");
727
728        // Mock dir_exists to return true only for ~/.bun/bin
729        let dir_exists = |dir: &Path| dir == Path::new("/home/alice/.bun/bin");
730
731        let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
732        assert_eq!(
733            enriched,
734            OsString::from("/usr/bin:/bin:/home/alice/.bun/bin")
735        );
736    }
737
738    #[test]
739    fn test_append_missing_standard_dirs_dedup_and_order() {
740        let home = OsStr::new("/home/alice");
741        // ~/.bun/bin is already in the path, but /opt/homebrew/bin is missing
742        let path = OsStr::new("/home/alice/.bun/bin:/usr/bin:/bin");
743
744        let dir_exists = |dir: &Path| {
745            dir == Path::new("/home/alice/.bun/bin") || dir == Path::new("/opt/homebrew/bin")
746        };
747
748        let enriched = append_missing_standard_dirs(path, Some(home), dir_exists);
749        // /opt/homebrew/bin should be appended at the end, and ~/.bun/bin should not be duplicated
750        assert_eq!(
751            enriched,
752            OsString::from("/home/alice/.bun/bin:/usr/bin:/bin:/opt/homebrew/bin")
753        );
754    }
755}