Skip to main content

routa_core/
shell_env.rs

1//! Resolve the user's full shell PATH for desktop GUI apps.
2//!
3//! GUI applications may inherit a minimal PATH:
4//! - macOS Finder/Dock: `/usr/bin:/bin:/usr/sbin:/sbin`
5//! - Windows: usually fine, but may miss user-installed tools
6//! - Linux: depends on the desktop environment
7//!
8//! This module recovers the user's login-shell PATH so we can find
9//! CLI tools like `opencode`, `claude`, `gemini`, etc.
10
11use std::path::{Path, PathBuf};
12use std::sync::OnceLock;
13
14static FULL_PATH: OnceLock<String> = OnceLock::new();
15
16/// Platform-specific PATH separator.
17#[cfg(windows)]
18const PATH_SEP: char = ';';
19#[cfg(not(windows))]
20const PATH_SEP: char = ':';
21
22/// Get the user's full shell PATH.
23/// Cached after the first call.
24pub fn full_path() -> &'static str {
25    FULL_PATH.get_or_init(resolve_full_path)
26}
27
28/// Resolve PATH by merging current PATH, login-shell PATH, and well-known dirs.
29fn 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    // 1. Try to get the real PATH from the user's login shell (Unix only)
43    #[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    // 2. Merge current process PATH
51    for p in current.split(PATH_SEP) {
52        add(p);
53    }
54
55    // 3. Add well-known directories
56    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/// Unix: try running the user's login shell to get $PATH.
70#[cfg(not(windows))]
71fn resolve_unix_shell_path() -> Option<String> {
72    // Try the user's configured login shell first
73    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
99/// Well-known directories where user CLI tools may be installed.
100fn 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        // Linuxbrew / Homebrew on Linux
125        dirs.push(home.join(".linuxbrew").join("bin"));
126        dirs.push(PathBuf::from("/home/linuxbrew/.linuxbrew/bin"));
127    }
128
129    #[cfg(windows)]
130    {
131        // Common Windows install locations
132        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        // Scoop
142        dirs.push(home.join("scoop").join("shims"));
143        // Chocolatey
144        if let Ok(choco) = std::env::var("ChocolateyInstall") {
145            dirs.push(PathBuf::from(choco).join("bin"));
146        }
147    }
148
149    dirs
150}
151
152/// Run a `which`-like check for a command using the full PATH.
153pub 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}