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(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        // Leak the string so we get 'static references — fine for a one-time init
162        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        // On Unix: just check the exact name
170        #[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        // On Windows: check with each PATHEXT extension
180        #[cfg(windows)]
181        {
182            // Check exact name first (e.g. cmd already has .exe)
183            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}