use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InstallKind {
Native,
NpmGlobal,
Homebrew,
Bundled,
Unknown,
}
impl InstallKind {
pub(crate) fn as_str(self) -> &'static str {
match self {
InstallKind::Native => "native",
InstallKind::NpmGlobal => "npm-global",
InstallKind::Homebrew => "homebrew",
InstallKind::Bundled => "bundled",
InstallKind::Unknown => "unknown",
}
}
}
pub(crate) fn resolve_on_path(program: &str, path: &str) -> Option<PathBuf> {
if program.contains('/') || (cfg!(windows) && program.contains('\\')) {
let candidate = PathBuf::from(program);
return is_executable_file(&candidate).then_some(candidate);
}
std::env::split_paths(path)
.filter(|dir| !dir.as_os_str().is_empty())
.map(|dir| dir.join(program))
.find(|candidate| is_executable_file(candidate))
}
pub(crate) fn classify(path: &Path, home: &Path, resource_dir: Option<&Path>) -> InstallKind {
let resolved = canonical_or_self(path);
let local_bin = home.join(".local");
if resolved.starts_with(&local_bin) {
return InstallKind::Native;
}
if let Some(resource_dir) = resource_dir {
if resolved.starts_with(canonical_or_self(resource_dir)) {
return InstallKind::Bundled;
}
}
if path_contains(&resolved, ".nvm") || path_contains(&resolved, "node_modules") {
return InstallKind::NpmGlobal;
}
if resolved.starts_with("/opt/homebrew") || resolved.starts_with("/usr/local") {
return InstallKind::Homebrew;
}
InstallKind::Unknown
}
fn canonical_or_self(path: &Path) -> PathBuf {
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn path_contains(path: &Path, needle: &str) -> bool {
path.components().any(|c| c.as_os_str() == needle)
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
path.is_file()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_native_local_bin() {
let home = Path::new("/Users/dev");
let path = Path::new("/Users/dev/.local/bin/claude");
assert_eq!(classify(path, home, None), InstallKind::Native);
}
#[test]
fn classify_nvm_is_npm_global() {
let home = Path::new("/Users/dev");
let path = Path::new("/Users/dev/.nvm/versions/node/v22.15.0/bin/claude");
assert_eq!(classify(path, home, None), InstallKind::NpmGlobal);
}
#[test]
fn classify_node_modules_is_npm_global() {
let home = Path::new("/Users/dev");
let path = Path::new("/Users/dev/Library/App/runtime/npm/lib/node_modules/.bin/claude");
assert_eq!(classify(path, home, None), InstallKind::NpmGlobal);
}
#[test]
fn classify_homebrew() {
let home = Path::new("/Users/dev");
assert_eq!(
classify(Path::new("/opt/homebrew/bin/claude"), home, None),
InstallKind::Homebrew,
);
assert_eq!(
classify(Path::new("/usr/local/bin/claude"), home, None),
InstallKind::Homebrew,
);
}
#[test]
fn classify_bundled_when_under_resource_dir() {
let home = Path::new("/Users/dev");
let resource = Path::new("/Applications/Compose.app/Contents/Resources");
let path = Path::new("/Applications/Compose.app/Contents/Resources/runtime/bin/claude");
assert_eq!(classify(path, home, Some(resource)), InstallKind::Bundled);
}
#[test]
fn classify_unknown_falls_through() {
let home = Path::new("/Users/dev");
assert_eq!(
classify(Path::new("/some/random/dir/claude"), home, None),
InstallKind::Unknown,
);
}
#[test]
fn resolve_picks_first_path_match_in_order() {
let dir = std::env::temp_dir().join(format!("claude-resolve-{}", std::process::id()));
let first = dir.join("first");
let second = dir.join("second");
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(&second).unwrap();
write_executable(&second.join("claude"));
let path = std::env::join_paths([&first, &second]).unwrap();
let path = path.to_str().unwrap();
assert_eq!(resolve_on_path("claude", path), Some(second.join("claude")));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn resolve_returns_none_when_absent() {
let path = format!("{}", std::env::temp_dir().join("definitely-empty-xyz").display());
assert_eq!(resolve_on_path("claude", &path), None);
}
#[cfg(unix)]
fn write_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt;
std::fs::write(path, b"#!/bin/sh\n").unwrap();
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(path, perms).unwrap();
}
#[cfg(not(unix))]
fn write_executable(path: &Path) {
std::fs::write(path, b"").unwrap();
}
}