forge_core_utils/
shell.rs1use std::{
4 collections::HashSet,
5 env::{join_paths, split_paths},
6 ffi::{OsStr, OsString},
7 path::{Path, PathBuf},
8};
9
10use crate::tokio::block_on;
11
12pub fn get_shell_command() -> (String, &'static str) {
18 if cfg!(windows) {
19 ("cmd".into(), "/C")
20 } else {
21 if let Ok(shell) = std::env::var("SHELL") {
23 let path = Path::new(&shell);
24 if path.is_absolute() && path.is_file() {
25 return (shell, "-c");
26 }
27 }
28 if std::path::Path::new("/bin/zsh").exists() {
30 ("zsh".into(), "-c")
31 } else if std::path::Path::new("/bin/bash").exists() {
32 ("bash".into(), "-c")
33 } else {
34 ("sh".into(), "-c")
35 }
36 }
37}
38
39pub async fn resolve_executable_path(executable: &str) -> Option<PathBuf> {
47 if executable.trim().is_empty() {
48 return None;
49 }
50
51 let path = Path::new(executable);
52 if path.is_absolute() && path.is_file() {
53 return Some(path.to_path_buf());
54 }
55
56 if let Some(found) = which(executable).await {
57 return Some(found);
58 }
59
60 if refresh_path().await
61 && let Some(found) = which(executable).await
62 {
63 return Some(found);
64 }
65
66 None
67}
68
69pub fn resolve_executable_path_blocking(executable: &str) -> Option<PathBuf> {
70 block_on(resolve_executable_path(executable))
71}
72
73pub fn merge_paths(primary: impl AsRef<OsStr>, secondary: impl AsRef<OsStr>) -> OsString {
80 let mut seen = HashSet::<PathBuf>::new();
81 let mut merged = Vec::<PathBuf>::new();
82
83 for p in split_paths(primary.as_ref()).chain(split_paths(secondary.as_ref())) {
84 if !p.as_os_str().is_empty() && seen.insert(p.clone()) {
85 merged.push(p);
86 }
87 }
88
89 join_paths(merged).unwrap_or_default()
90}
91
92async fn refresh_path() -> bool {
93 let Some(refreshed) = get_fresh_path().await else {
94 return false;
95 };
96 let existing = std::env::var_os("PATH").unwrap_or_default();
97 let refreshed_os = OsString::from(&refreshed);
98 let merged = merge_paths(&existing, refreshed_os);
99 if merged == existing {
100 return false;
101 }
102 tracing::debug!(?existing, ?refreshed, ?merged, "Refreshed PATH");
103 unsafe {
104 std::env::set_var("PATH", &merged);
105 }
106 true
107}
108
109async fn which(executable: &str) -> Option<PathBuf> {
110 let executable = executable.to_string();
111 tokio::task::spawn_blocking(move || which::which(executable))
112 .await
113 .ok()
114 .and_then(|result| result.ok())
115}
116
117#[cfg(not(windows))]
118async fn get_fresh_path() -> Option<String> {
119 use std::time::Duration;
120
121 use tokio::process::Command;
122
123 async fn run(shell: &Path, login: bool) -> Option<String> {
124 let mut cmd = Command::new(shell);
125 if login {
126 cmd.arg("-l");
127 }
128 cmd.arg("-c")
129 .arg("printf '%s' \"$PATH\"")
130 .env("TERM", "dumb")
131 .kill_on_drop(true);
132
133 const PATH_REFRESH_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
134
135 let child = cmd.spawn().ok()?;
136 let output = match tokio::time::timeout(
137 PATH_REFRESH_COMMAND_TIMEOUT,
138 child.wait_with_output(),
139 )
140 .await
141 {
142 Ok(Ok(output)) => output,
143 Ok(Err(err)) => {
144 tracing::debug!(
145 shell = %shell.display(),
146 ?err,
147 "Failed to retrieve PATH from login shell"
148 );
149 return None;
150 }
151 Err(_) => {
152 tracing::warn!(
153 shell = %shell.display(),
154 timeout_secs = PATH_REFRESH_COMMAND_TIMEOUT.as_secs(),
155 "Timed out retrieving PATH from login shell"
156 );
157 return None;
158 }
159 };
160
161 if !output.status.success() {
162 return None;
163 }
164 let path = String::from_utf8(output.stdout).ok()?.trim().to_string();
165 if path.is_empty() { None } else { Some(path) }
166 }
167
168 let mut paths = Vec::new();
169
170 let shells = vec![
171 (PathBuf::from("/bin/zsh"), true),
172 (PathBuf::from("/bin/bash"), true),
173 (PathBuf::from("/bin/sh"), false),
174 ];
175
176 let mut current_shell_name = None;
177 if let Ok(shell) = std::env::var("SHELL") {
178 let path = Path::new(&shell);
179 if path.is_absolute() && path.is_file() {
180 current_shell_name = path.file_name().and_then(OsStr::to_str).map(String::from);
181 if let Some(path) = run(path, true).await {
182 paths.push(path);
183 }
184 }
185 }
186
187 for (shell_path, login) in shells {
188 if !shell_path.exists() {
189 continue;
190 }
191 let shell_name = shell_path
192 .file_name()
193 .and_then(OsStr::to_str)
194 .map(String::from);
195 if current_shell_name != shell_name
196 && let Some(path) = run(&shell_path, login).await
197 {
198 paths.push(path);
199 }
200 }
201
202 if paths.is_empty() {
203 return None;
204 }
205
206 paths
207 .into_iter()
208 .map(OsString::from)
209 .reduce(|a, b| merge_paths(&a, &b))
210 .map(|merged| merged.to_string_lossy().into_owned())
211}
212
213#[cfg(windows)]
214async fn get_fresh_path() -> Option<String> {
215 tokio::task::spawn_blocking(get_fresh_path_blocking)
216 .await
217 .ok()
218 .flatten()
219}
220
221#[cfg(windows)]
222fn get_fresh_path_blocking() -> Option<String> {
223 use std::{
224 ffi::{OsStr, OsString},
225 os::windows::ffi::{OsStrExt, OsStringExt},
226 };
227
228 use winreg::{HKEY, RegKey, enums::*};
229
230 fn expand_env_vars(input: &OsStr) -> OsString {
232 use windows_sys::Win32::System::Environment::ExpandEnvironmentStringsW;
233
234 let wide: Vec<u16> = input.encode_wide().chain(Some(0)).collect();
235 unsafe {
236 let needed = ExpandEnvironmentStringsW(wide.as_ptr(), std::ptr::null_mut(), 0);
237 if needed == 0 {
238 return input.to_os_string();
239 }
240 let mut buf = vec![0u16; needed as usize];
241 let written = ExpandEnvironmentStringsW(wide.as_ptr(), buf.as_mut_ptr(), needed);
242 if written == 0 {
243 return input.to_os_string();
244 }
245 OsString::from_wide(&buf[..(written as usize).saturating_sub(1)])
247 }
248 }
249
250 fn read_registry_path(root: HKEY, subkey: &str) -> Option<OsString> {
251 let key = RegKey::predef(root)
252 .open_subkey_with_flags(subkey, KEY_READ)
253 .ok()?;
254 key.get_value::<String, _>("Path").ok().map(OsString::from)
255 }
256
257 let mut paths: Vec<OsString> = Vec::new();
258
259 if let Some(user_path) = read_registry_path(HKEY_CURRENT_USER, "Environment") {
260 paths.push(expand_env_vars(&user_path));
261 }
262
263 if let Some(machine_path) = read_registry_path(
264 HKEY_LOCAL_MACHINE,
265 r"System\CurrentControlSet\Control\Session Manager\Environment",
266 ) {
267 paths.push(expand_env_vars(&machine_path));
268 }
269
270 if paths.is_empty() {
271 return None;
272 }
273
274 paths
275 .into_iter()
276 .map(OsString::from)
277 .reduce(|a, b| merge_paths(&a, &b))
278 .map(|merged| merged.to_string_lossy().into_owned())
279}