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(windows)]
158 let extensions: Vec<&str> = {
159 let pathext =
160 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD;.PS1".to_string());
161 let leaked: &'static str = Box::leak(pathext.into_boxed_str());
163 leaked.split(';').collect()
164 };
165
166 for dir in path.split(PATH_SEP) {
167 let base = Path::new(dir).join(cmd);
168
169 #[cfg(not(windows))]
171 {
172 if base.is_file() {
173 let result = base.to_string_lossy().to_string();
174 tracing::debug!("[shell_env] Found '{}' at: {}", cmd, result);
175 return Some(result);
176 }
177 }
178
179 #[cfg(windows)]
181 {
182 if base.is_file() {
184 return Some(base.to_string_lossy().to_string());
185 }
186 for ext in &extensions {
187 let with_ext = base.with_extension(ext.trim_start_matches('.'));
188 if with_ext.is_file() {
189 return Some(with_ext.to_string_lossy().to_string());
190 }
191 }
192 }
193 }
194 tracing::warn!("[shell_env] Command '{}' not found in PATH", cmd);
195 None
196}