1use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14static FULL_PATH: OnceLock<String> = OnceLock::new();
15
16#[cfg(windows)]
18const PATH_SEP: char = ';';
19#[cfg(not(windows))]
20const PATH_SEP: char = ':';
21
22pub fn full_path() -> &'static str {
25 FULL_PATH.get_or_init(resolve_full_path)
26}
27
28fn resolve_full_path() -> String {
30 let current = std::env::var("PATH").unwrap_or_default();
31 let home = dirs::home_dir().unwrap_or_default();
32
33 let mut seen = std::collections::HashSet::new();
34 let mut parts: Vec<String> = Vec::new();
35
36 let mut add = |p: &str| {
37 if !p.is_empty() && seen.insert(p.to_string()) {
38 parts.push(p.to_string());
39 }
40 };
41
42 #[cfg(not(windows))]
44 if let Some(shell_path) = resolve_unix_shell_path() {
45 for p in shell_path.split(PATH_SEP) {
46 add(p);
47 }
48 }
49
50 for p in current.split(PATH_SEP) {
52 add(p);
53 }
54
55 for dir in well_known_dirs(&home) {
57 let d = dir.to_string_lossy().to_string();
58 if dir.is_dir() {
59 add(&d);
60 }
61 }
62
63 let result = parts.join(&PATH_SEP.to_string());
64 tracing::info!("[shell_env] Resolved PATH ({} entries)", parts.len());
65 tracing::debug!("[shell_env] Full PATH: {}", result);
66 result
67}
68
69#[cfg(not(windows))]
71fn resolve_unix_shell_path() -> Option<String> {
72 let login_shell = std::env::var("SHELL").unwrap_or_default();
74 let shells_to_try: Vec<&str> = if login_shell.is_empty() {
75 vec!["/bin/zsh", "/bin/bash", "/bin/sh"]
76 } else {
77 vec![&login_shell, "/bin/zsh", "/bin/bash", "/bin/sh"]
78 };
79
80 for shell in shells_to_try {
81 if let Ok(output) = std::process::Command::new(shell)
82 .args(["-l", "-c", "echo $PATH"])
83 .output()
84 {
85 if output.status.success() {
86 if let Ok(path) = String::from_utf8(output.stdout) {
87 let trimmed = path.trim().to_string();
88 if !trimmed.is_empty() {
89 return Some(trimmed);
90 }
91 }
92 }
93 }
94 }
95
96 None
97}
98
99fn well_known_dirs(home: &Path) -> Vec<PathBuf> {
101 let mut dirs = vec![
102 home.join(".local").join("bin"),
103 home.join(".cargo").join("bin"),
104 home.join(".opencode").join("bin"),
105 home.join(".bun").join("bin"),
106 home.join("bin"),
107 home.join("go").join("bin"),
108 home.join(".npm-global").join("bin"),
109 ];
110
111 #[cfg(target_os = "macos")]
112 {
113 dirs.push(PathBuf::from("/opt/homebrew/bin"));
114 dirs.push(PathBuf::from("/opt/homebrew/sbin"));
115 dirs.push(PathBuf::from("/usr/local/bin"));
116 dirs.push(PathBuf::from("/usr/local/sbin"));
117 }
118
119 #[cfg(target_os = "linux")]
120 {
121 dirs.push(PathBuf::from("/usr/local/bin"));
122 dirs.push(PathBuf::from("/usr/local/sbin"));
123 dirs.push(PathBuf::from("/snap/bin"));
124 dirs.push(home.join(".linuxbrew").join("bin"));
126 dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
127 }
128
129 #[cfg(windows)]
130 {
131 if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
133 let lad = PathBuf::from(&local_app_data);
134 dirs.push(lad.join("Programs"));
135 dirs.push(lad.join("Microsoft").join("WinGet").join("Packages"));
136 }
137 if let Ok(app_data) = std::env::var("APPDATA") {
138 let ad = PathBuf::from(&app_data);
139 dirs.push(ad.join("npm"));
140 }
141 dirs.push(home.join("scoop").join("shims"));
143 if let Ok(choco) = std::env::var("ChocolateyInstall") {
145 dirs.push(PathBuf::from(choco).join("bin"));
146 }
147 }
148
149 dirs
150}
151
152pub fn which(cmd: &str) -> Option<String> {
154 let path = full_path();
155 tracing::debug!("[shell_env] Looking for '{}' in PATH", cmd);
156
157 #[cfg(not(windows))]
158 {
159 for dir in path.split(PATH_SEP) {
160 let base = Path::new(dir).join(cmd);
161 if base.is_file() {
162 let result = base.to_string_lossy().to_string();
163 tracing::debug!("[shell_env] Found '{}' at: {}", cmd, result);
164 return Some(result);
165 }
166 }
167 }
168
169 #[cfg(windows)]
170 {
171 let pathext =
172 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_string());
173 if let Some(resolved) = which_in_path_windows(cmd, path, &pathext) {
174 tracing::debug!("[shell_env] Found '{}' at: {}", cmd, resolved);
175 return Some(resolved);
176 }
177 }
178
179 tracing::warn!("[shell_env] Command '{}' not found in PATH", cmd);
180 None
181}
182
183#[cfg(windows)]
184fn which_in_path_windows(cmd: &str, path: &str, pathext: &str) -> Option<String> {
185 let extensions: Vec<&str> = pathext
186 .split(';')
187 .map(str::trim)
188 .filter(|ext| !ext.is_empty())
189 .collect();
190 let cmd_has_extension = Path::new(cmd).extension().is_some();
191
192 for dir in path.split(PATH_SEP) {
193 if dir.trim().is_empty() {
194 continue;
195 }
196
197 let base = Path::new(dir).join(cmd);
198
199 if cmd_has_extension && base.is_file() {
200 return Some(base.to_string_lossy().to_string());
201 }
202
203 if !cmd_has_extension {
204 for ext in &extensions {
205 let with_ext = base.with_extension(ext.trim_start_matches('.'));
206 if with_ext.is_file() {
207 return Some(with_ext.to_string_lossy().to_string());
208 }
209 }
210
211 if base.is_file() {
212 return Some(base.to_string_lossy().to_string());
213 }
214 }
215 }
216
217 None
218}
219
220#[cfg(all(test, windows))]
221mod tests {
222 use super::which_in_path_windows;
223
224 #[test]
225 fn windows_which_prefers_spawnable_extension_before_shim() {
226 let temp = tempfile::tempdir().expect("tempdir");
227 let cmd_shim = temp.path().join("npx");
228 let cmd_file = temp.path().join("npx.cmd");
229
230 std::fs::write(&cmd_shim, "shim").expect("write shim");
231 std::fs::write(&cmd_file, "@echo off").expect("write cmd");
232
233 let resolved = which_in_path_windows(
234 "npx",
235 temp.path().to_string_lossy().as_ref(),
236 ".COM;.EXE;.BAT;.CMD;.PS1",
237 )
238 .expect("should resolve npx");
239
240 assert_eq!(
241 resolved.to_lowercase(),
242 cmd_file.to_string_lossy().to_lowercase()
243 );
244 }
245
246 #[test]
247 fn windows_which_keeps_explicit_extension_resolution() {
248 let temp = tempfile::tempdir().expect("tempdir");
249 let exe_file = temp.path().join("uv.exe");
250 std::fs::write(&exe_file, "binary").expect("write exe");
251
252 let resolved = which_in_path_windows(
253 "uv.exe",
254 temp.path().to_string_lossy().as_ref(),
255 ".COM;.EXE;.BAT;.CMD;.PS1",
256 )
257 .expect("should resolve uv.exe");
258
259 assert_eq!(
260 resolved.to_lowercase(),
261 exe_file.to_string_lossy().to_lowercase()
262 );
263 }
264}