agentmux_common/
toolchain_path.rs1use 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());
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#[cfg(unix)]
106fn resolve_user_shell() -> String {
107 std::env::var("SHELL")
108 .ok()
109 .filter(|s| !s.is_empty())
110 .unwrap_or_else(|| {
111 if cfg!(target_os = "macos") {
112 "/bin/zsh".to_string()
113 } else {
114 "/bin/bash".to_string()
115 }
116 })
117}
118
119#[cfg(unix)]
124fn capture_login_shell_path(shell: &str) -> Option<String> {
125 use std::io::Read;
126 use std::process::{Command, Stdio};
127 use std::sync::mpsc;
128 use std::time::Duration;
129
130 let script = r#"printf '__AMUX_PATH__%s__AMUX_END__' "$PATH""#;
133
134 let mut child = Command::new(shell)
135 .args(["-lic", script])
136 .stdin(Stdio::null())
137 .stdout(Stdio::piped())
138 .stderr(Stdio::null())
139 .spawn()
140 .ok()?;
141
142 let mut stdout = child.stdout.take()?;
143 let (tx, rx) = mpsc::channel();
145 std::thread::spawn(move || {
146 let mut buf = String::new();
147 let _ = stdout.read_to_string(&mut buf);
148 let _ = tx.send(buf);
149 });
150
151 let buf = match rx.recv_timeout(Duration::from_secs(2)) {
152 Ok(b) => {
153 let _ = child.wait();
154 b
155 }
156 Err(_) => {
157 let _ = child.kill();
160 let _ = child.wait();
161 return None;
162 }
163 };
164
165 parse_sentinel(&buf)
166}
167
168fn parse_sentinel(buf: &str) -> Option<String> {
170 const START: &str = "__AMUX_PATH__";
171 const END: &str = "__AMUX_END__";
172 let s = buf.find(START)? + START.len();
173 let e = buf[s..].find(END)? + s;
174 let path = &buf[s..e];
175 if path.is_empty() {
176 None
177 } else {
178 Some(path.to_string())
179 }
180}
181
182#[cfg(unix)]
185fn wellknown_dirs() -> Vec<String> {
186 let home = std::env::var("HOME").unwrap_or_default();
187 let mut candidates: Vec<String> = Vec::new();
188
189 if !home.is_empty() {
190 if let Some(bin) = nvm_current_bin(&home) {
191 candidates.push(bin);
192 }
193 }
194 candidates.push("/opt/homebrew/bin".to_string());
195 candidates.push("/opt/homebrew/sbin".to_string());
196 candidates.push("/usr/local/bin".to_string());
197 if !home.is_empty() {
198 candidates.push(format!("{home}/.local/bin"));
199 candidates.push(format!("{home}/.cargo/bin"));
200 }
201 candidates.push("/opt/local/bin".to_string());
202
203 #[cfg(target_os = "linux")]
204 {
205 candidates.push("/snap/bin".to_string());
206 candidates.push("/var/lib/flatpak/exports/bin".to_string());
207 if !home.is_empty() {
208 candidates.push(format!("{home}/.local/share/flatpak/exports/bin"));
209 }
210 }
211
212 candidates
213 .into_iter()
214 .filter(|d| Path::new(d).is_dir())
215 .collect()
216}
217
218#[cfg(unix)]
221fn nvm_current_bin(home: &str) -> Option<String> {
222 let versions_dir = format!("{home}/.nvm/versions/node");
223 if !Path::new(&versions_dir).is_dir() {
224 return None;
225 }
226
227 if let Ok(alias) = std::fs::read_to_string(format!("{home}/.nvm/alias/default")) {
229 let a = alias.trim();
230 if !a.is_empty() {
231 let v = if a.starts_with('v') {
232 a.to_string()
233 } else {
234 format!("v{a}")
235 };
236 let bin = format!("{versions_dir}/{v}/bin");
237 if Path::new(&bin).is_dir() {
238 return Some(bin);
239 }
240 }
241 }
242
243 let mut versions: Vec<(u64, u64, u64, String)> = std::fs::read_dir(&versions_dir)
246 .ok()?
247 .filter_map(|e| e.ok())
248 .filter_map(|e| e.file_name().into_string().ok())
249 .filter(|n| n.starts_with('v'))
250 .map(|n| {
251 let (a, b, c) = parse_semverish(&n);
252 (a, b, c, n)
253 })
254 .collect();
255 versions.sort();
256 versions
257 .last()
258 .map(|(_, _, _, n)| format!("{versions_dir}/{n}/bin"))
259}
260
261fn parse_semverish(v: &str) -> (u64, u64, u64) {
264 let core = v.strip_prefix('v').unwrap_or(v);
265 let mut it = core.split('.');
266 let p = |o: Option<&str>| o.and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
267 (p(it.next()), p(it.next()), p(it.next()))
268}
269
270fn build_enriched(inherited: &str, login_shell: Option<&str>, wellknown: &[String]) -> EnrichedPath {
274 let mut seen: HashSet<&str> = HashSet::new();
275 let mut out: Vec<&str> = Vec::new();
276 let used_login = login_shell.is_some();
277
278 if let Some(ls) = login_shell {
279 for e in ls.split(':') {
280 if !e.is_empty() && seen.insert(e) {
281 out.push(e);
282 }
283 }
284 }
285
286 let before = out.len();
287 for d in wellknown {
288 if !d.is_empty() && seen.insert(d.as_str()) {
289 out.push(d.as_str());
290 }
291 }
292 let wellknown_added = out.len() > before;
293
294 for e in inherited.split(':') {
295 if !e.is_empty() && seen.insert(e) {
296 out.push(e);
297 }
298 }
299
300 let path = out.join(":");
301 let changed = path != normalize(inherited);
303 let source = if !changed {
304 PathSource::Inherited
305 } else if used_login {
306 PathSource::LoginShell
307 } else if wellknown_added {
308 PathSource::FallbackDirs
309 } else {
310 PathSource::Inherited
311 };
312
313 EnrichedPath { path, source }
314}
315
316fn normalize(path: &str) -> String {
318 let mut seen = HashSet::new();
319 path.split(':')
320 .filter(|e| !e.is_empty() && seen.insert(*e))
321 .collect::<Vec<_>>()
322 .join(":")
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn parse_sentinel_ignores_rc_noise() {
331 let buf = "welcome to my shell!\nsome banner\n__AMUX_PATH__/opt/homebrew/bin:/usr/bin__AMUX_END__";
332 assert_eq!(
333 parse_sentinel(buf).as_deref(),
334 Some("/opt/homebrew/bin:/usr/bin")
335 );
336 }
337
338 #[test]
339 fn parse_sentinel_none_when_missing_or_empty() {
340 assert_eq!(parse_sentinel("no markers here"), None);
341 assert_eq!(parse_sentinel("__AMUX_PATH____AMUX_END__"), None);
342 }
343
344 #[test]
345 fn launchd_default_detection() {
346 assert!(looks_like_launchd_default("/usr/bin:/bin:/usr/sbin:/sbin"));
347 assert!(looks_like_launchd_default("/bin:/usr/bin"));
348 assert!(!looks_like_launchd_default(
349 "/opt/homebrew/bin:/usr/bin:/bin"
350 ));
351 assert!(!looks_like_launchd_default(""));
352 }
353
354 #[test]
355 fn semverish_sorts_numerically() {
356 assert!(parse_semverish("v10.0.0") > parse_semverish("v9.99.99"));
357 assert_eq!(parse_semverish("v20.18.1"), (20, 18, 1));
358 assert_eq!(parse_semverish("vbogus"), (0, 0, 0));
359 }
360
361 #[test]
362 fn build_prepends_login_shell_and_preserves_inherited() {
363 let e = build_enriched(
364 "/usr/bin:/bin",
365 Some("/opt/homebrew/bin:/usr/bin:/bin"),
366 &[],
367 );
368 assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
370 assert_eq!(e.source, PathSource::LoginShell);
371 }
372
373 #[test]
374 fn build_falls_back_to_wellknown_when_no_login_shell() {
375 let e = build_enriched(
376 "/usr/bin:/bin",
377 None,
378 &["/opt/homebrew/bin".to_string()],
379 );
380 assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/bin");
381 assert_eq!(e.source, PathSource::FallbackDirs);
382 }
383
384 #[test]
385 fn build_reports_inherited_when_nothing_added() {
386 let e = build_enriched("/usr/bin:/bin", Some("/usr/bin:/bin"), &[]);
389 assert_eq!(e.path, "/usr/bin:/bin");
390 assert_eq!(e.source, PathSource::Inherited);
391 }
392
393 #[test]
394 fn build_dedups_across_all_sources() {
395 let e = build_enriched(
396 "/usr/bin:/bin:/usr/bin",
397 Some("/opt/homebrew/bin:/usr/bin"),
398 &["/opt/homebrew/bin".to_string(), "/usr/local/bin".to_string()],
399 );
400 assert_eq!(e.path, "/opt/homebrew/bin:/usr/bin:/usr/local/bin:/bin");
401 }
402
403 #[cfg(unix)]
404 #[test]
405 fn capture_via_real_sh_roundtrips_path() {
406 let got = capture_login_shell_path("/bin/sh");
409 assert!(got.is_some(), "expected a captured PATH from /bin/sh");
411 assert!(!got.unwrap().is_empty());
412 }
413}