1use std::collections::HashSet;
22use std::path::Path;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum PathSource {
28 Inherited,
30 LoginShell,
32 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#[derive(Debug, Clone)]
48pub struct EnrichedPath {
49 pub path: String,
50 pub source: PathSource,
51}
52
53const LAUNCHD_DEFAULT_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
55
56pub 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
77pub 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
87pub fn enrich_current_process_path() -> PathSource {
93 let current = std::env::var("PATH").unwrap_or_default();
94 if !looks_like_launchd_default(¤t) {
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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct EnrichOutcome {
107 pub source: PathSource,
108 pub from_cache: bool,
112}
113
114pub 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(¤t) {
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, ¤t),
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#[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 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#[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#[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 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#[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#[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 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 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 let _ = child.kill();
343 let _ = child.wait();
344 return None;
345 }
346 };
347
348 parse_sentinel(&buf)
349}
350
351#[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#[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#[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 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 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#[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#[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 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#[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 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 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 let got = capture_login_shell_path("/bin/sh", None);
596 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}