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
108    #[test]
109    fn find_on_path_manual_returns_null_when_path_unset() {
110        let _guard = crate::test_env::process_env_lock();
111        let saved = std::env::var_os("PATH");
112        std::env::remove_var("PATH");
113        assert!(find_on_path_manual("aft-nonexistent-tool-xyzzy").is_none());
114        if let Some(path) = saved {
115            std::env::set_var("PATH", path);
116        }
117    }
118
119    #[cfg(unix)]
120    #[test]
121    fn find_on_path_manual_finds_executable_in_single_dir() {
122        let _guard = crate::test_env::process_env_lock();
123        let dir = tempfile::tempdir().unwrap();
124        let bin_path = dir.path().join("opencode-test-bin");
125        fs::write(&bin_path, "#!/bin/sh\necho ok\n").unwrap();
126        let mut perms = fs::metadata(&bin_path).unwrap().permissions();
127        use std::os::unix::fs::PermissionsExt;
128        perms.set_mode(0o755);
129        fs::set_permissions(&bin_path, perms).unwrap();
130
131        let saved = std::env::var_os("PATH");
132        std::env::set_var("PATH", dir.path());
133        let found = find_on_path_manual("opencode-test-bin");
134        if let Some(path) = saved {
135            std::env::set_var("PATH", path);
136        } else {
137            std::env::remove_var("PATH");
138        }
139
140        assert_eq!(found.as_deref(), Some(bin_path.as_path()));
141    }
142
143    #[cfg(unix)]
144    #[test]
145    fn find_on_path_manual_skips_non_executable_file() {
146        let _guard = crate::test_env::process_env_lock();
147        let dir = tempfile::tempdir().unwrap();
148        let bin_path = dir.path().join("opencode-test-bin");
149        fs::write(&bin_path, "not executable\n").unwrap();
150
151        let saved = std::env::var_os("PATH");
152        std::env::set_var("PATH", dir.path());
153        let found = find_on_path_manual("opencode-test-bin");
154        if let Some(path) = saved {
155            std::env::set_var("PATH", path);
156        } else {
157            std::env::remove_var("PATH");
158        }
159
160        assert!(found.is_none());
161    }
162
163    #[cfg(windows)]
164    #[test]
165    fn probe_tool_in_dir_finds_cmd_shim() {
166        let dir = tempfile::tempdir().unwrap();
167        let cmd_path = dir.path().join("biome.cmd");
168        fs::write(&cmd_path, "@echo off\n").unwrap();
169        assert_eq!(
170            probe_tool_in_dir(dir.path(), "biome").as_deref(),
171            Some(cmd_path.as_path())
172        );
173    }
174}