1use crate::types::CliName;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5use tokio::process::Command;
6
7static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
12
13fn home_dir() -> Option<PathBuf> {
14 std::env::var("HOME").ok().map(PathBuf::from)
15}
16
17fn is_executable(path: &Path) -> bool {
18 #[cfg(unix)]
19 {
20 use std::os::unix::fs::PermissionsExt;
21 path.is_file()
22 && std::fs::metadata(path)
23 .map(|m| m.permissions().mode() & 0o111 != 0)
24 .unwrap_or(false)
25 }
26 #[cfg(not(unix))]
27 {
28 path.is_file()
29 }
30}
31
32async fn which(binary: &str) -> Option<String> {
33 let output = Command::new("which").arg(binary).output().await.ok()?;
34 if output.status.success() {
35 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
36 if !path.is_empty() {
37 return Some(path);
38 }
39 }
40 None
41}
42
43fn find_nvm_binary(binary: &str) -> Option<String> {
44 if let Ok(nvm_bin) = std::env::var("NVM_BIN") {
46 let p = PathBuf::from(&nvm_bin).join(binary);
47 if is_executable(&p) {
48 return Some(p.to_string_lossy().into_owned());
49 }
50 }
51
52 let home = home_dir()?;
54 let nvm_versions = home.join(".nvm/versions/node");
55 if !nvm_versions.is_dir() {
56 return None;
57 }
58
59 let mut versions: Vec<PathBuf> = std::fs::read_dir(&nvm_versions)
60 .ok()?
61 .filter_map(|e| e.ok())
62 .map(|e| e.path())
63 .filter(|p| p.is_dir())
64 .collect();
65
66 versions.sort_by(|a, b| {
69 let parse_ver = |p: &Path| -> (u64, u64, u64) {
70 let name = p.file_name().unwrap_or_default().to_string_lossy();
71 let s = name.strip_prefix('v').unwrap_or(&name);
72 let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
73 (
74 parts.next().unwrap_or(0),
75 parts.next().unwrap_or(0),
76 parts.next().unwrap_or(0),
77 )
78 };
79 parse_ver(b).cmp(&parse_ver(a))
80 });
81
82 for dir in versions {
83 let p = dir.join("bin").join(binary);
84 if is_executable(&p) {
85 return Some(p.to_string_lossy().into_owned());
86 }
87 }
88
89 None
90}
91
92const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
93
94const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
95
96const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
97
98async fn search_for_binary(cli: CliName) -> Option<String> {
99 let binary = cli.to_string();
100
101 if let Some(path) = which(&binary).await {
103 return Some(path);
104 }
105
106 if let Some(path) = find_nvm_binary(&binary) {
108 return Some(path);
109 }
110
111 for dir in SEARCH_PATHS {
113 let p = PathBuf::from(dir).join(&binary);
114 if is_executable(&p) {
115 return Some(p.to_string_lossy().into_owned());
116 }
117 }
118
119 if let Some(home) = home_dir() {
121 for rel in HOME_RELATIVE_PATHS {
122 let p = home.join(rel).join(&binary);
123 if is_executable(&p) {
124 return Some(p.to_string_lossy().into_owned());
125 }
126 }
127
128 if cli == CliName::Claude {
130 for rel in CLAUDE_EXTRA_PATHS {
131 let p = home.join(rel);
132 if is_executable(&p) {
133 return Some(p.to_string_lossy().into_owned());
134 }
135 }
136 }
137 }
138
139 None
140}
141
142pub async fn discover_binary(cli: CliName) -> Option<String> {
144 {
146 let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
147 if let Some(cache) = guard.as_ref() {
148 if let Some(path) = cache.get(&cli) {
149 if is_executable(Path::new(path)) {
150 return Some(path.clone());
151 }
152 }
153 }
154 }
155
156 let path = search_for_binary(cli).await?;
157
158 {
160 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
161 let cache = guard.get_or_insert_with(HashMap::new);
162 cache.insert(cli, path.clone());
163 }
164
165 Some(path)
166}
167
168pub async fn discover_all() -> Vec<(CliName, String)> {
170 let (claude, codex, gemini) = tokio::join!(
171 discover_binary(CliName::Claude),
172 discover_binary(CliName::Codex),
173 discover_binary(CliName::Gemini),
174 );
175
176 let mut results = Vec::new();
177 if let Some(path) = claude {
178 results.push((CliName::Claude, path));
179 }
180 if let Some(path) = codex {
181 results.push((CliName::Codex, path));
182 }
183 if let Some(path) = gemini {
184 results.push((CliName::Gemini, path));
185 }
186 results
187}
188
189pub async fn discover_first() -> Option<(CliName, String)> {
193 let (claude, codex, gemini) = tokio::join!(
194 discover_binary(CliName::Claude),
195 discover_binary(CliName::Codex),
196 discover_binary(CliName::Gemini),
197 );
198
199 if let Some(path) = claude {
200 return Some((CliName::Claude, path));
201 }
202 if let Some(path) = codex {
203 return Some((CliName::Codex, path));
204 }
205 if let Some(path) = gemini {
206 return Some((CliName::Gemini, path));
207 }
208 None
209}
210
211pub fn clear_cache() {
213 let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
214 *guard = None;
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[test]
222 fn nvm_version_sorting() {
223 let parse_ver = |name: &str| -> (u64, u64, u64) {
225 let s = name.strip_prefix('v').unwrap_or(name);
226 let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
227 (
228 parts.next().unwrap_or(0),
229 parts.next().unwrap_or(0),
230 parts.next().unwrap_or(0),
231 )
232 };
233
234 assert_eq!(parse_ver("v20.11.0"), (20, 11, 0));
235 assert_eq!(parse_ver("v18.17.1"), (18, 17, 1));
236 assert_eq!(parse_ver("v22.0.0"), (22, 0, 0));
237 assert_eq!(parse_ver("invalid"), (0, 0, 0));
238 assert_eq!(parse_ver("v1"), (1, 0, 0));
239
240 let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
242 versions.sort_by(|a, b| parse_ver(b).cmp(&parse_ver(a)));
243 assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
244 }
245
246 #[cfg(unix)]
247 #[test]
248 fn is_executable_checks_permission_bits() {
249 use std::os::unix::fs::PermissionsExt;
250 let dir = tempfile::tempdir().unwrap();
251
252 let non_exec = dir.path().join("not-exec");
253 std::fs::write(&non_exec, "#!/bin/sh").unwrap();
254 std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
255 assert!(!is_executable(&non_exec));
256
257 let exec = dir.path().join("exec");
258 std::fs::write(&exec, "#!/bin/sh").unwrap();
259 std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
260 assert!(is_executable(&exec));
261
262 assert!(!is_executable(Path::new("/does/not/exist")));
263 }
264
265 #[test]
266 fn clear_cache_resets_state() {
267 {
269 let mut guard = CACHE.lock().unwrap();
270 let cache = guard.get_or_insert_with(HashMap::new);
271 cache.insert(CliName::Claude, "/usr/bin/claude".into());
272 }
273
274 clear_cache();
275
276 let guard = CACHE.lock().unwrap();
277 assert!(guard.is_none());
278 }
279
280 #[test]
281 fn cli_name_display() {
282 assert_eq!(CliName::Claude.to_string(), "claude");
283 assert_eq!(CliName::Codex.to_string(), "codex");
284 assert_eq!(CliName::Gemini.to_string(), "gemini");
285 }
286}