Skip to main content

winx_code_agent/utils/
executable.rs

1//! Portable executable discovery.
2//!
3//! Winx used to probe for programs with `sh -c 'command -v …'`, which assumes a
4//! POSIX shell on `PATH`. Native Windows has no `sh`, and its `bash.exe` may be
5//! the WSL launcher in `System32` rather than a usable shell, so lookups are
6//! done directly against `PATH` (honoring `PATHEXT` on Windows) and the known
7//! Git for Windows install locations.
8
9use std::path::{Path, PathBuf};
10
11/// Shell family Winx drives in the PTY. It decides the prompt bootstrap, the
12/// completion marker, and which statement policy applies to commands.
13#[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
34/// Classify a shell selection (`zsh`, `/bin/zsh`, `pwsh`, `C:\\...\\pwsh.exe`,
35/// `powershell`) by its basename; anything unrecognized is driven as bash.
36pub 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
56/// PowerShell to drive when requested (or when Windows has no Git for Windows
57/// bash). PowerShell 7 (`pwsh`) is preferred; Windows PowerShell 5.1 is the
58/// fallback on Windows only.
59pub 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
82/// Locate `program` on `PATH`. A name with a path separator is checked as-is.
83/// On Windows, a bare name is tried with every `PATHEXT` suffix (`.exe`,
84/// `.cmd`, …) as well as verbatim.
85pub 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
100/// Whether `program` can be found on `PATH`. Best-effort: used for advisory
101/// hints and shell selection, never as a security boundary.
102pub fn is_available(program: &str) -> bool {
103    find_on_path(program).is_some()
104}
105
106/// The `bash` executable Winx should drive. On Unix this is whatever `PATH`
107/// resolves. On Windows the Git for Windows shell is preferred and
108/// `System32\bash.exe` (the WSL launcher, which needs a distribution and does
109/// not honor `PROMPT_COMMAND`) is never selected.
110pub 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/// `bash.exe` under `System32` and the Store alias under
143/// `%LOCALAPPDATA%\Microsoft\WindowsApps` both launch WSL, not a shell.
144#[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}