Skip to main content

onyx/shell/
mod.rs

1//! Locate command-line executables. Each `lookup` target is treated as
2//! a `PATH` name when it has no separators, or as a file path otherwise.
3
4use std::path::{Path, PathBuf};
5
6use crate::osinfo::Platform;
7
8#[derive(Debug, thiserror::Error)]
9pub enum ShellError {
10    #[error("shell: binary not found")]
11    BinaryNotFound,
12}
13
14#[derive(Debug, Default, Clone)]
15pub struct Resolver {
16    targets: Vec<String>,
17}
18
19impl Resolver {
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    pub fn lookup(mut self, target: impl Into<String>) -> Self {
25        let t = target.into();
26        if !t.is_empty() {
27            self.targets.push(t);
28        }
29        self
30    }
31
32    pub fn lookups<I, S>(mut self, targets: I) -> Self
33    where
34        I: IntoIterator<Item = S>,
35        S: Into<String>,
36    {
37        for t in targets {
38            self = self.lookup(t);
39        }
40        self
41    }
42
43    pub fn resolve(&self) -> Result<PathBuf, ShellError> {
44        for t in &self.targets {
45            if is_path_like(t) {
46                let p = PathBuf::from(t);
47                if is_executable_file(&p) {
48                    return Ok(p);
49                }
50                continue;
51            }
52            if let Some(found) = look_path(t) {
53                return Ok(found);
54            }
55        }
56        Err(ShellError::BinaryNotFound)
57    }
58}
59
60pub fn list_npm_global_bin_dirs() -> Vec<PathBuf> {
61    let platform = Platform::current();
62    let home = std::env::var_os("HOME").map(PathBuf::from);
63    if platform.is_windows() {
64        let mut out = Vec::new();
65        if let Some(appdata) = std::env::var_os("APPDATA") {
66            let mut p = PathBuf::from(appdata);
67            p.push("npm");
68            out.push(p);
69        }
70        return out;
71    }
72    if let Some(h) = home {
73        return vec![
74            h.join(".npm-global").join("bin"),
75            h.join(".local/share/npm/bin"),
76        ];
77    }
78    Vec::new()
79}
80
81pub fn list_user_local_bin_dirs() -> Vec<PathBuf> {
82    if let Some(h) = std::env::var_os("HOME") {
83        let home = PathBuf::from(h);
84        return vec![home.join(".local/bin"), home.join("bin")];
85    }
86    Vec::new()
87}
88
89pub fn list_system_bin_dirs() -> Vec<PathBuf> {
90    let platform = Platform::current();
91    if platform.is_windows() {
92        return Vec::new();
93    }
94    if platform.is_darwin() {
95        return vec![
96            PathBuf::from("/usr/local/bin"),
97            PathBuf::from("/opt/homebrew/bin"),
98            PathBuf::from("/usr/bin"),
99        ];
100    }
101    vec![PathBuf::from("/usr/local/bin"), PathBuf::from("/usr/bin")]
102}
103
104pub fn list_windows_application_dirs(application_name: &str) -> Vec<PathBuf> {
105    if !Platform::current().is_windows() || application_name.is_empty() {
106        return Vec::new();
107    }
108    let mut out = Vec::new();
109    if let Some(v) = std::env::var_os("LOCALAPPDATA") {
110        let mut p = PathBuf::from(v);
111        p.push("Programs");
112        p.push(application_name);
113        out.push(p);
114    }
115    if let Some(v) = std::env::var_os("ProgramFiles") {
116        let mut p = PathBuf::from(v);
117        p.push(application_name);
118        out.push(p);
119    }
120    if let Some(v) = std::env::var_os("ProgramFiles(x86)") {
121        let mut p = PathBuf::from(v);
122        p.push(application_name);
123        out.push(p);
124    }
125    out
126}
127
128fn is_path_like(s: &str) -> bool {
129    if s.contains('/') || s.contains('\\') {
130        return true;
131    }
132    let bytes = s.as_bytes();
133    bytes.len() >= 2 && bytes[1] == b':'
134}
135
136fn look_path(name: &str) -> Option<PathBuf> {
137    let path_env = std::env::var_os("PATH")?;
138    for dir in std::env::split_paths(&path_env) {
139        let candidate = dir.join(name);
140        if is_executable_file(&candidate) {
141            return Some(candidate);
142        }
143    }
144    None
145}
146
147fn is_executable_file(path: &Path) -> bool {
148    match std::fs::metadata(path) {
149        Ok(m) => m.is_file(),
150        Err(_) => false,
151    }
152}
153
154/// login_path returns the PATH as seen by the user's interactive login shell.
155/// GUI applications launched outside a terminal on macOS and Linux do not
156/// inherit the shell's PATH additions; this recovers them. On Windows, and when
157/// the shell cannot be queried, it falls back to the current process PATH.
158pub fn login_path() -> String {
159    let process_path = std::env::var("PATH").unwrap_or_default();
160    if Platform::current().is_windows() {
161        return process_path;
162    }
163    let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
164    let output = std::process::Command::new(shell)
165        .args(["-l", "-c", "echo $PATH"])
166        .output();
167    match output {
168        Ok(out) => {
169            let trimmed = String::from_utf8_lossy(&out.stdout).trim().to_string();
170            if trimmed.is_empty() {
171                process_path
172            } else {
173                trimmed
174            }
175        }
176        Err(_) => process_path,
177    }
178}
179
180/// enriched_environ returns the current environment with PATH merged from the
181/// process and login-shell PATH, order preserved and duplicates dropped. Use it
182/// when spawning child processes that must find user-installed tools.
183pub fn enriched_environ() -> Vec<(String, String)> {
184    let merged = merge_path(&std::env::var("PATH").unwrap_or_default(), &login_path());
185    std::env::vars()
186        .map(|(key, value)| {
187            if key == "PATH" {
188                (key, merged.clone())
189            } else {
190                (key, value)
191            }
192        })
193        .collect()
194}
195
196fn merge_path(first: &str, second: &str) -> String {
197    let separator = if Platform::current().is_windows() {
198        ';'
199    } else {
200        ':'
201    };
202    let mut seen = std::collections::HashSet::new();
203    let mut ordered = Vec::new();
204    for group in [first, second] {
205        for segment in group.split(separator).filter(|s| !s.is_empty()) {
206            if seen.insert(segment.to_string()) {
207                ordered.push(segment.to_string());
208            }
209        }
210    }
211    ordered.join(&separator.to_string())
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn login_path_is_non_empty() {
220        assert!(!login_path().is_empty());
221    }
222
223    #[test]
224    fn merge_path_drops_duplicates() {
225        let sep = if Platform::current().is_windows() {
226            ';'
227        } else {
228            ':'
229        };
230        let merged = merge_path(&format!("a{sep}b"), &format!("b{sep}c"));
231        assert_eq!(merged, format!("a{sep}b{sep}c"));
232    }
233
234    #[test]
235    fn resolve_fails_when_nothing_matches() {
236        let result = Resolver::new()
237            .lookup("definitely-not-a-real-binary-xyz")
238            .lookup("/definitely/not/a/path/binary")
239            .resolve();
240        assert!(matches!(result, Err(ShellError::BinaryNotFound)));
241    }
242
243    #[test]
244    fn resolve_finds_explicit_path() {
245        let dir = tempfile::tempdir().expect("tempdir");
246        let bin = dir.path().join("fakebin");
247        std::fs::write(&bin, "#!/bin/sh\nexit 0\n").expect("write");
248        let resolved = Resolver::new()
249            .lookup("definitely-not-a-real-binary-xyz")
250            .lookup(bin.to_string_lossy().to_string())
251            .resolve()
252            .expect("resolve");
253        assert_eq!(resolved, bin);
254    }
255
256    #[test]
257    fn ignores_empty_inputs() {
258        let result = Resolver::new().lookup("").lookup("").resolve();
259        assert!(matches!(result, Err(ShellError::BinaryNotFound)));
260    }
261
262    #[test]
263    fn is_path_like_detects_separators() {
264        assert!(!is_path_like("claude"));
265        assert!(is_path_like("/opt/homebrew/bin/claude"));
266        assert!(is_path_like("./bin/foo"));
267        assert!(is_path_like(r"C:\Program Files\app.exe"));
268        assert!(!is_path_like(""));
269    }
270}