1use 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
154pub 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
180pub 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}