winx_code_agent/utils/
executable.rs1use std::path::{Path, PathBuf};
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum ShellKind {
15 Bash,
16 Zsh,
17 PowerShell,
18}
19
20impl ShellKind {
21 pub fn is_powershell(self) -> bool {
22 matches!(self, Self::PowerShell)
23 }
24
25 pub fn as_str(self) -> &'static str {
26 match self {
27 Self::Bash => "bash",
28 Self::Zsh => "zsh",
29 Self::PowerShell => "powershell",
30 }
31 }
32}
33
34pub fn shell_kind_of(shell: &str) -> ShellKind {
37 let name = Path::new(shell)
38 .file_name()
39 .and_then(|name| name.to_str())
40 .map(str::to_ascii_lowercase)
41 .unwrap_or_default();
42 let stem = name.strip_suffix(".exe").unwrap_or(&name);
43 if stem == "zsh"
44 || stem
45 .strip_prefix("zsh-")
46 .is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
47 {
48 ShellKind::Zsh
49 } else if stem == "pwsh" || stem == "powershell" || stem.starts_with("pwsh-") {
50 ShellKind::PowerShell
51 } else {
52 ShellKind::Bash
53 }
54}
55
56pub fn powershell_executable() -> Option<PathBuf> {
60 if let Some(found) = find_on_path("pwsh") {
61 return Some(found);
62 }
63 #[cfg(windows)]
64 {
65 if let Some(found) = find_on_path("powershell") {
66 return Some(found);
67 }
68 let system_root = std::env::var_os("SystemRoot")?;
69 let legacy = PathBuf::from(system_root)
70 .join("System32")
71 .join("WindowsPowerShell")
72 .join("v1.0")
73 .join("powershell.exe");
74 is_executable_file(&legacy).then_some(legacy)
75 }
76 #[cfg(not(windows))]
77 {
78 None
79 }
80}
81
82pub fn find_on_path(program: &str) -> Option<PathBuf> {
86 if program.is_empty() {
87 return None;
88 }
89 let direct = Path::new(program);
90 if direct.components().count() > 1 {
91 return is_executable_file(direct).then(|| direct.to_path_buf());
92 }
93 let path = std::env::var_os("PATH")?;
94 std::env::split_paths(&path)
95 .filter(|dir| !dir.as_os_str().is_empty())
96 .flat_map(|dir| candidate_names(program).into_iter().map(move |name| dir.join(name)))
97 .find(|candidate| is_executable_file(candidate))
98}
99
100pub fn is_available(program: &str) -> bool {
103 find_on_path(program).is_some()
104}
105
106pub fn bash_executable() -> Option<PathBuf> {
111 #[cfg(windows)]
112 {
113 windows_bash_executable()
114 }
115 #[cfg(not(windows))]
116 {
117 find_on_path("bash")
118 }
119}
120
121#[cfg(windows)]
122fn windows_bash_executable() -> Option<PathBuf> {
123 let mut candidates = Vec::new();
124 for root in ["ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"] {
125 if let Some(base) = std::env::var_os(root) {
126 candidates.push(PathBuf::from(&base).join("Git").join("bin").join("bash.exe"));
127 candidates
128 .push(PathBuf::from(base).join("Git").join("usr").join("bin").join("bash.exe"));
129 }
130 }
131 if let Some(base) = std::env::var_os("LOCALAPPDATA") {
132 let user_git = PathBuf::from(base).join("Programs").join("Git");
133 candidates.push(user_git.join("bin").join("bash.exe"));
134 candidates.push(user_git.join("usr").join("bin").join("bash.exe"));
135 }
136 candidates
137 .into_iter()
138 .find(|candidate| is_executable_file(candidate))
139 .or_else(|| find_on_path("bash").filter(|found| !is_windows_system_launcher(found)))
140}
141
142#[cfg(windows)]
145fn is_windows_system_launcher(path: &Path) -> bool {
146 let mut launcher_dirs = Vec::new();
147 if let Some(system_root) = std::env::var_os("SystemRoot") {
148 launcher_dirs.push(PathBuf::from(system_root).join("System32"));
149 }
150 if let Some(local) = std::env::var_os("LOCALAPPDATA") {
151 launcher_dirs.push(PathBuf::from(local).join("Microsoft").join("WindowsApps"));
152 }
153 path.ancestors().any(|ancestor| {
154 launcher_dirs.iter().any(|dir| ancestor.as_os_str().eq_ignore_ascii_case(dir.as_os_str()))
155 })
156}
157
158#[cfg(windows)]
159fn candidate_names(program: &str) -> Vec<String> {
160 let has_extension = Path::new(program).extension().is_some();
161 let mut names = vec![program.to_string()];
162 if !has_extension {
163 let pathext =
164 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
165 names.extend(
166 pathext
167 .split(';')
168 .filter(|extension| !extension.is_empty())
169 .map(|extension| format!("{program}{}", extension.to_ascii_lowercase())),
170 );
171 }
172 names
173}
174
175#[cfg(not(windows))]
176fn candidate_names(program: &str) -> Vec<String> {
177 vec![program.to_string()]
178}
179
180#[cfg(unix)]
181fn is_executable_file(path: &Path) -> bool {
182 use std::os::unix::fs::PermissionsExt as _;
183 std::fs::metadata(path)
184 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
185}
186
187#[cfg(not(unix))]
188fn is_executable_file(path: &Path) -> bool {
189 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
190}
191
192#[cfg(test)]
193mod tests {
194 #![allow(clippy::expect_used)]
195 use super::{find_on_path, is_available};
196
197 #[test]
198 fn empty_and_missing_programs_are_not_found() {
199 assert!(find_on_path("").is_none());
200 assert!(!is_available("winx-definitely-not-a-real-program-9f8e7d"));
201 }
202
203 #[cfg(unix)]
204 #[test]
205 fn resolves_a_standard_unix_tool_and_rejects_non_executables() {
206 assert!(is_available("sh"));
207 let temp = tempfile::tempdir().expect("temp dir");
208 let plain = temp.path().join("not-executable");
209 std::fs::write(&plain, b"data").expect("write");
210 assert!(find_on_path(plain.to_str().expect("utf-8 path")).is_none());
211 }
212}