forge_core_utils/
shell.rs

1//! Cross-platform shell command utilities
2
3use 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
12/// Returns the appropriate shell command and argument for the current platform.
13///
14/// Returns (shell_program, shell_arg) where:
15/// - Windows: ("cmd", "/C")
16/// - Unix-like: ("sh", "-c") or ("bash", "-c") if available
17pub fn get_shell_command() -> (String, &'static str) {
18    if cfg!(windows) {
19        ("cmd".into(), "/C")
20    } else {
21        // Prefer SHELL env var if set and valid
22        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        // Prefer zsh or bash if available, fallback to sh
29        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
39/// Resolve an executable by name, falling back to a refreshed PATH if needed.
40///
41/// The search order is:
42/// 1. Explicit paths (absolute or containing a separator).
43/// 2. The current process PATH via `which`.
44/// 3. A platform-specific refresh of PATH (login shell on Unix, PowerShell on Windows),
45///    after which we re-run the `which` lookup and update the process PATH for future calls.
46pub 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
73/// Merge two PATH strings into a single, de-duplicated PATH.
74///
75/// - Keeps the order of entries from `primary`.
76/// - Appends only *unseen* entries from `secondary`.
77/// - Ignores empty components.
78/// - Returns a platform-correct PATH string (using the OS separator).
79pub 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    // Expand %VARS% for registry PATH entries
231    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            // written includes the trailing NUL when it fits
246            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}