use crate::types::CliName;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tokio::process::Command;
static CACHE: Mutex<Option<HashMap<CliName, String>>> = Mutex::new(None);
fn home_dir() -> Option<PathBuf> {
std::env::var("HOME").ok().map(PathBuf::from)
}
fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
path.is_file()
&& std::fs::metadata(path)
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
path.is_file()
}
}
async fn which(binary: &str) -> Option<String> {
let output = Command::new("which").arg(binary).output().await.ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
None
}
fn find_nvm_binary(binary: &str) -> Option<String> {
if let Ok(nvm_bin) = std::env::var("NVM_BIN") {
let p = PathBuf::from(&nvm_bin).join(binary);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
let home = home_dir()?;
let nvm_versions = home.join(".nvm/versions/node");
if !nvm_versions.is_dir() {
return None;
}
let mut versions: Vec<PathBuf> = std::fs::read_dir(&nvm_versions)
.ok()?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort_by(|a, b| {
let parse_ver = |p: &Path| -> (u64, u64, u64) {
let name = p.file_name().unwrap_or_default().to_string_lossy();
let s = name.strip_prefix('v').unwrap_or(&name);
let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
};
parse_ver(b).cmp(&parse_ver(a))
});
for dir in versions {
let p = dir.join("bin").join(binary);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
None
}
const SEARCH_PATHS: &[&str] = &["/opt/homebrew/bin", "/usr/local/bin"];
const HOME_RELATIVE_PATHS: &[&str] = &[".local/bin", ".bun/bin", ".npm-global/bin"];
const CLAUDE_EXTRA_PATHS: &[&str] = &[".claude/local/claude"];
async fn search_for_binary(cli: CliName) -> Option<String> {
let binary = cli.to_string();
if let Some(path) = which(&binary).await {
return Some(path);
}
if let Some(path) = find_nvm_binary(&binary) {
return Some(path);
}
for dir in SEARCH_PATHS {
let p = PathBuf::from(dir).join(&binary);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
if let Some(home) = home_dir() {
for rel in HOME_RELATIVE_PATHS {
let p = home.join(rel).join(&binary);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
if cli == CliName::Claude {
for rel in CLAUDE_EXTRA_PATHS {
let p = home.join(rel);
if is_executable(&p) {
return Some(p.to_string_lossy().into_owned());
}
}
}
}
None
}
pub async fn discover_binary(cli: CliName) -> Option<String> {
{
let guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(cache) = guard.as_ref() {
if let Some(path) = cache.get(&cli) {
if is_executable(Path::new(path)) {
return Some(path.clone());
}
}
}
}
let path = search_for_binary(cli).await?;
{
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(cli, path.clone());
}
Some(path)
}
pub async fn discover_all() -> Vec<(CliName, String)> {
let (claude, codex, gemini) = tokio::join!(
discover_binary(CliName::Claude),
discover_binary(CliName::Codex),
discover_binary(CliName::Gemini),
);
let mut results = Vec::new();
if let Some(path) = claude {
results.push((CliName::Claude, path));
}
if let Some(path) = codex {
results.push((CliName::Codex, path));
}
if let Some(path) = gemini {
results.push((CliName::Gemini, path));
}
results
}
pub async fn discover_first() -> Option<(CliName, String)> {
let (claude, codex, gemini) = tokio::join!(
discover_binary(CliName::Claude),
discover_binary(CliName::Codex),
discover_binary(CliName::Gemini),
);
if let Some(path) = claude {
return Some((CliName::Claude, path));
}
if let Some(path) = codex {
return Some((CliName::Codex, path));
}
if let Some(path) = gemini {
return Some((CliName::Gemini, path));
}
None
}
pub fn clear_cache() {
let mut guard = CACHE.lock().unwrap_or_else(|e| e.into_inner());
*guard = None;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nvm_version_sorting() {
let parse_ver = |name: &str| -> (u64, u64, u64) {
let s = name.strip_prefix('v').unwrap_or(name);
let mut parts = s.split('.').map(|n| n.parse::<u64>().unwrap_or(0));
(
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
parts.next().unwrap_or(0),
)
};
assert_eq!(parse_ver("v20.11.0"), (20, 11, 0));
assert_eq!(parse_ver("v18.17.1"), (18, 17, 1));
assert_eq!(parse_ver("v22.0.0"), (22, 0, 0));
assert_eq!(parse_ver("invalid"), (0, 0, 0));
assert_eq!(parse_ver("v1"), (1, 0, 0));
let mut versions = vec!["v18.17.1", "v22.0.0", "v20.11.0"];
versions.sort_by_key(|v| std::cmp::Reverse(parse_ver(v)));
assert_eq!(versions, vec!["v22.0.0", "v20.11.0", "v18.17.1"]);
}
#[cfg(unix)]
#[test]
fn is_executable_checks_permission_bits() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let non_exec = dir.path().join("not-exec");
std::fs::write(&non_exec, "#!/bin/sh").unwrap();
std::fs::set_permissions(&non_exec, std::fs::Permissions::from_mode(0o644)).unwrap();
assert!(!is_executable(&non_exec));
let exec = dir.path().join("exec");
std::fs::write(&exec, "#!/bin/sh").unwrap();
std::fs::set_permissions(&exec, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(is_executable(&exec));
assert!(!is_executable(Path::new("/does/not/exist")));
}
#[test]
fn clear_cache_resets_state() {
{
let mut guard = CACHE.lock().unwrap();
let cache = guard.get_or_insert_with(HashMap::new);
cache.insert(CliName::Claude, "/usr/bin/claude".into());
}
clear_cache();
let guard = CACHE.lock().unwrap();
assert!(guard.is_none());
}
#[test]
fn cli_name_display() {
assert_eq!(CliName::Claude.to_string(), "claude");
assert_eq!(CliName::Codex.to_string(), "codex");
assert_eq!(CliName::Gemini.to_string(), "gemini");
}
}