Skip to main content

aft/
tool_path.rs

1//! Cross-platform tool binary resolution on PATH and well-known install dirs.
2//!
3//! PATH walking follows the same contract as cortexkit/magic-context
4//! `packages/cli/src/lib/find-on-path.ts` (PR #75): probe filesystem entries
5//! without shelling out to `which`/`where`, and on Windows try
6//! `.exe` → `.cmd` → `.bat` → `.com` per PATH directory.
7
8use std::path::{Path, PathBuf};
9
10/// Resolve `binary` on the process `PATH` (PATHEXT-aware on Windows via `which`).
11pub(crate) fn resolve_on_path(binary: &str) -> Option<PathBuf> {
12    if let Ok(path) = which::which(binary) {
13        return Some(path);
14    }
15    find_on_path_manual(binary)
16}
17
18/// Walk `PATH` left-to-right without spawning a subprocess.
19pub(crate) fn find_on_path_manual(binary: &str) -> Option<PathBuf> {
20    let path_var = std::env::var_os("PATH")?;
21    for dir in std::env::split_paths(&path_var) {
22        if dir.as_os_str().is_empty() {
23            continue;
24        }
25        if let Some(found) = probe_tool_in_dir(&dir, binary) {
26            return Some(found);
27        }
28    }
29    None
30}
31
32fn path_looks_like_tool(path: &Path) -> bool {
33    let Ok(metadata) = std::fs::metadata(path) else {
34        return false;
35    };
36    if !metadata.is_file() {
37        return false;
38    }
39    #[cfg(unix)]
40    {
41        use std::os::unix::fs::PermissionsExt;
42        metadata.permissions().mode() & 0o111 != 0
43    }
44    #[cfg(not(unix))]
45    {
46        let _ = metadata;
47        true
48    }
49}
50
51/// Check `dir/<binary>` and, on Windows, `dir/<binary>.exe|.cmd|.bat|.com`.
52pub(crate) fn probe_tool_in_dir(dir: &Path, binary: &str) -> Option<PathBuf> {
53    if !dir.is_dir() {
54        return None;
55    }
56
57    let direct = dir.join(binary);
58    if path_looks_like_tool(&direct) {
59        return Some(direct);
60    }
61
62    if cfg!(windows) {
63        for ext in ["exe", "cmd", "bat", "com"] {
64            let candidate = dir.join(format!("{binary}.{ext}"));
65            if path_looks_like_tool(&candidate) {
66                return Some(candidate);
67            }
68        }
69    }
70
71    None
72}
73
74/// Extra bin directories GUI-launched hosts often omit from `PATH`.
75#[cfg(windows)]
76pub(crate) fn well_known_windows_bin_dirs(userprofile: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
77    let mut dirs: Vec<PathBuf> = Vec::with_capacity(10);
78    dirs.push(PathBuf::from(r"C:\Go\bin"));
79    dirs.push(PathBuf::from(r"C:\Program Files\Go\bin"));
80    dirs.push(PathBuf::from(r"C:\Program Files\nodejs"));
81    if let Some(appdata) = std::env::var_os("APPDATA") {
82        dirs.push(PathBuf::from(appdata).join("npm"));
83    }
84    if let Some(local) = std::env::var_os("LOCALAPPDATA") {
85        let local_path = PathBuf::from(local);
86        dirs.push(local_path.join("pnpm"));
87        dirs.push(local_path.join("Programs").join("Python"));
88    }
89    if let Some(up) = userprofile {
90        let up_path = PathBuf::from(up);
91        dirs.push(up_path.join(r".cargo\bin"));
92        dirs.push(up_path.join(r"go\bin"));
93        dirs.push(up_path.join("scoop").join("shims"));
94    }
95    dirs
96}
97
98#[cfg(not(windows))]
99pub(crate) fn well_known_windows_bin_dirs(_userprofile: Option<&std::ffi::OsStr>) -> Vec<PathBuf> {
100    Vec::new()
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use std::fs;
107    use std::sync::Mutex;
108
109    static PATH_ENV_LOCK: Mutex<()> = Mutex::new(());
110
111    #[test]
112    fn find_on_path_manual_returns_null_when_path_unset() {
113        let _guard = PATH_ENV_LOCK.lock().unwrap();
114        let saved = std::env::var_os("PATH");
115        std::env::remove_var("PATH");
116        assert!(find_on_path_manual("aft-nonexistent-tool-xyzzy").is_none());
117        if let Some(path) = saved {
118            std::env::set_var("PATH", path);
119        }
120    }
121
122    #[cfg(unix)]
123    #[test]
124    fn find_on_path_manual_finds_executable_in_single_dir() {
125        let _guard = PATH_ENV_LOCK.lock().unwrap();
126        let dir = tempfile::tempdir().unwrap();
127        let bin_path = dir.path().join("opencode-test-bin");
128        fs::write(&bin_path, "#!/bin/sh\necho ok\n").unwrap();
129        let mut perms = fs::metadata(&bin_path).unwrap().permissions();
130        use std::os::unix::fs::PermissionsExt;
131        perms.set_mode(0o755);
132        fs::set_permissions(&bin_path, perms).unwrap();
133
134        let saved = std::env::var_os("PATH");
135        std::env::set_var("PATH", dir.path());
136        let found = find_on_path_manual("opencode-test-bin");
137        if let Some(path) = saved {
138            std::env::set_var("PATH", path);
139        } else {
140            std::env::remove_var("PATH");
141        }
142
143        assert_eq!(found.as_deref(), Some(bin_path.as_path()));
144    }
145
146    #[cfg(unix)]
147    #[test]
148    fn find_on_path_manual_skips_non_executable_file() {
149        let _guard = PATH_ENV_LOCK.lock().unwrap();
150        let dir = tempfile::tempdir().unwrap();
151        let bin_path = dir.path().join("opencode-test-bin");
152        fs::write(&bin_path, "not executable\n").unwrap();
153
154        let saved = std::env::var_os("PATH");
155        std::env::set_var("PATH", dir.path());
156        let found = find_on_path_manual("opencode-test-bin");
157        if let Some(path) = saved {
158            std::env::set_var("PATH", path);
159        } else {
160            std::env::remove_var("PATH");
161        }
162
163        assert!(found.is_none());
164    }
165
166    #[cfg(windows)]
167    #[test]
168    fn probe_tool_in_dir_finds_cmd_shim() {
169        let dir = tempfile::tempdir().unwrap();
170        let cmd_path = dir.path().join("biome.cmd");
171        fs::write(&cmd_path, "@echo off\n").unwrap();
172        assert_eq!(
173            probe_tool_in_dir(dir.path(), "biome").as_deref(),
174            Some(cmd_path.as_path())
175        );
176    }
177}