use std::path::{Component, Path};
use crate::path_analysis;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Durability {
Durable,
NotDurable,
Unknown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
ProjectLocal,
Global,
}
const PROJECT_LOCAL_PATTERNS: &[&str] = &[
"/.direnv/",
"/node_modules/.bin/",
"/.venv/",
"/venv/bin/",
"/.tox/",
];
const VERSIONED_INSTALL_PATTERNS: &[&str] = &[
"/Cellar/",
"/Caskroom/",
"/mise/installs/",
"/.mise/installs/",
"/asdf/installs/",
"/.asdf/installs/",
"/nix/store/",
"/.nvm/versions/",
"/.fnm/node-versions/",
"/.rustup/toolchains/",
"/.volta/tools/",
"/.sdkman/candidates/",
"/.pyenv/versions/",
"/.rbenv/versions/",
"/.goenv/versions/",
"/.proto/tools/",
"/scoop/apps/",
"/chocolatey/lib/",
"/WinGet/Packages/",
"/aquaproj-aqua/internal/pkgs/",
"/claude/versions/",
"/cursor-agent/versions/",
"/cpython@",
];
const DURABLE_DIRECT_DIRS: &[&str] = &[
"/usr/bin",
"/bin",
"/usr/local/bin",
"/usr/sbin",
"/sbin",
"/opt/homebrew/bin",
"/opt/homebrew/sbin",
"/run/current-system/sw/bin",
"/nix/var/nix/profiles/default/bin",
];
const STANDARD_SHIM_SUFFIXES: &[&str] = &[
"/.local/share/mise/shims",
"/.mise/shims",
"/.asdf/shims",
"/.pyenv/shims",
"/.rbenv/shims",
"/.goenv/shims",
"/.proto/shims",
];
const HOME_ANCHORED_DIRECT_DIRS: &[&str] = &[
"/.cargo/bin", "/go/bin", "/.moon/bin", ];
fn norm(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn scope_of(path: &Path) -> Scope {
let s = norm(path);
if PROJECT_LOCAL_PATTERNS.iter().any(|p| s.contains(p)) {
Scope::ProjectLocal
} else {
Scope::Global
}
}
fn is_versioned_install(path: &Path) -> bool {
let s = norm(path);
VERSIONED_INSTALL_PATTERNS.iter().any(|p| s.contains(p))
}
fn normal_components(path: &Path) -> Vec<String> {
path.components()
.filter_map(|c| match c {
Component::Normal(os) => Some(os.to_string_lossy().into_owned()),
_ => None,
})
.collect()
}
fn is_etc_profiles_per_user_bin(path: &Path) -> bool {
if !path.is_absolute() {
return false;
}
let c = normal_components(path);
c.len() == 6
&& c[0] == "etc"
&& c[1] == "profiles"
&& c[2] == "per-user"
&& c[4] == "bin"
}
fn is_durable_direct_dir(path: &Path) -> bool {
if !path.is_absolute() {
return false;
}
let Some(parent) = path.parent() else {
return false;
};
if path.file_name().is_none() {
return false;
}
let parent_s = norm(parent);
DURABLE_DIRECT_DIRS.iter().any(|&d| parent_s == d)
}
fn is_home_anchored_suffix_dir(path: &Path, home: Option<&Path>, suffixes: &[&str]) -> bool {
let Some(home) = home else {
return false;
};
if !path.is_absolute() {
return false;
}
let home_s = {
let mut h = norm(home);
while h.ends_with('/') {
h.pop();
}
h
};
if home_s.is_empty() {
return false;
}
let s = norm(path);
suffixes.iter().any(|suffix| {
let with_sep = format!("{home_s}{suffix}/");
s.strip_prefix(&with_sep)
.is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
})
}
fn is_standard_shim(path: &Path, home: Option<&Path>) -> bool {
path_analysis::is_shim_path(path)
&& is_home_anchored_suffix_dir(path, home, STANDARD_SHIM_SUFFIXES)
}
fn is_home_anchored_direct_dir(path: &Path, home: Option<&Path>) -> bool {
is_home_anchored_suffix_dir(path, home, HOME_ANCHORED_DIRECT_DIRS)
}
fn is_durable_location(path: &Path, home: Option<&Path>) -> bool {
is_durable_direct_dir(path)
|| is_etc_profiles_per_user_bin(path)
|| is_standard_shim(path, home)
|| is_home_anchored_direct_dir(path, home)
}
pub fn judge(path: &Path) -> Durability {
let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
judge_with_home(path, home.as_deref())
}
fn judge_with_home(path: &Path, home: Option<&Path>) -> Durability {
if is_versioned_install(path)
|| path_analysis::is_ephemeral(path)
|| path_analysis::is_build_output(path)
|| scope_of(path) == Scope::ProjectLocal
{
return Durability::NotDurable;
}
if is_durable_location(path, home) {
return Durability::Durable;
}
Durability::Unknown
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
const HOME: &str = "/home/u";
fn judge_str(s: &str) -> Durability {
judge_with_home(Path::new(s), Some(Path::new(HOME)))
}
#[test]
fn homebrew_cellar_is_not_durable() {
assert_eq!(
judge_str("/opt/homebrew/Cellar/node/26.0.0/bin/node"),
Durability::NotDurable
);
}
#[test]
fn nix_store_is_not_durable() {
assert_eq!(
judge_str("/nix/store/abc123hash-git-2.54.0/bin/git"),
Durability::NotDurable
);
}
#[test]
fn mise_installs_is_not_durable() {
assert_eq!(
judge_str("/home/u/.local/share/mise/installs/node/22.22.0/bin/node"),
Durability::NotDurable
);
}
#[test]
fn claude_versions_is_not_durable() {
assert_eq!(
judge_str("/home/u/.local/share/claude/versions/2.1.177/bin/claude"),
Durability::NotDurable
);
}
#[test]
fn cursor_agent_versions_is_not_durable() {
assert_eq!(
judge_str(
"/home/u/.local/share/cursor-agent/versions/2025.09.17-25b418f/bin/cursor-agent"
),
Durability::NotDurable
);
}
#[test]
fn generic_versions_segment_is_not_versioned_install() {
assert_eq!(
judge_str("/home/u/myapp/versions/data/tool"),
Durability::Unknown
);
}
#[test]
fn rye_cpython_is_not_durable() {
assert_eq!(
judge_str("/home/u/.dotfiles/cache/rye/py/cpython@3.12.8/bin/python"),
Durability::NotDurable
);
}
#[test]
fn aqua_internal_pkgs_is_not_durable() {
assert_eq!(
judge_str("/home/u/.local/share/aquaproj-aqua/internal/pkgs/foo/v2.56.2/bin/aqua"),
Durability::NotDurable
);
}
#[test]
fn cache_dir_is_not_durable() {
assert_eq!(
judge_str("/home/u/.cache/jj-worktree/bin/git"),
Durability::NotDurable
);
}
#[test]
fn build_output_is_not_durable() {
assert_eq!(
judge_str("/home/u/project/target/release/myapp"),
Durability::NotDurable
);
}
#[test]
fn direnv_is_not_durable() {
assert_eq!(
judge_str("/home/u/project/.direnv/python-3.12/bin/python"),
Durability::NotDurable
);
}
#[test]
fn venv_is_not_durable() {
assert_eq!(
judge_str("/home/u/project/.venv/bin/python"),
Durability::NotDurable
);
}
#[test]
fn node_modules_bin_is_not_durable() {
assert_eq!(
judge_str("/home/u/project/node_modules/.bin/eslint"),
Durability::NotDurable
);
}
#[cfg(unix)]
#[test]
fn homebrew_bin_is_durable() {
assert_eq!(judge_str("/opt/homebrew/bin/node"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn homebrew_sbin_is_durable() {
assert_eq!(judge_str("/opt/homebrew/sbin/foo"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn usr_bin_is_durable() {
assert_eq!(judge_str("/usr/bin/git"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn usr_sbin_is_durable() {
assert_eq!(judge_str("/usr/sbin/foo"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn sbin_is_durable() {
assert_eq!(judge_str("/sbin/init"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn bin_is_durable() {
assert_eq!(judge_str("/bin/sh"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn usr_local_bin_is_durable() {
assert_eq!(judge_str("/usr/local/bin/node"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn mise_shim_is_durable() {
assert_eq!(
judge_str("/home/u/.local/share/mise/shims/node"),
Durability::Durable
);
}
#[cfg(unix)]
#[test]
fn run_current_system_is_durable() {
assert_eq!(
judge_str("/run/current-system/sw/bin/git"),
Durability::Durable
);
}
#[cfg(unix)]
#[test]
fn etc_profiles_per_user_is_durable() {
assert_eq!(
judge_str("/etc/profiles/per-user/alice/bin/curl"),
Durability::Durable
);
}
#[cfg(unix)]
#[test]
fn nix_default_profile_is_durable() {
assert_eq!(
judge_str("/nix/var/nix/profiles/default/bin/nix"),
Durability::Durable
);
}
#[cfg(unix)]
#[test]
fn cargo_bin_is_durable() {
assert_eq!(judge_str("/home/u/.cargo/bin/ripgrep"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn go_bin_is_durable() {
assert_eq!(judge_str("/home/u/go/bin/gopls"), Durability::Durable);
}
#[cfg(unix)]
#[test]
fn moon_bin_is_durable() {
assert_eq!(judge_str("/home/u/.moon/bin/moon"), Durability::Durable);
}
#[test]
fn etc_profiles_per_user_deep_is_not_durable() {
assert_eq!(
judge_with_home(
Path::new("/etc/profiles/per-user/alice/lib/python/bin/foo"),
Some(Path::new(HOME))
),
Durability::Unknown
);
}
#[test]
fn etc_profiles_per_user_relative_is_not_durable() {
assert_eq!(
judge_with_home(
Path::new("home/u/etc/profiles/per-user/alice/bin/foo"),
Some(Path::new(HOME))
),
Durability::Unknown
);
}
#[test]
fn usr_local_binutils_is_unknown() {
assert_eq!(judge_str("/usr/local/binutils/foo"), Durability::Unknown);
}
#[test]
fn opt_homebrew_binfoo_is_unknown() {
assert_eq!(judge_str("/opt/homebrew/binfoo/git"), Durability::Unknown);
}
#[test]
fn usr_bin_nested_is_unknown() {
assert_eq!(judge_str("/usr/bin/subdir/foo"), Durability::Unknown);
}
#[test]
fn project_local_mise_shim_is_unknown() {
assert_eq!(
judge_with_home(Path::new("/repo/.mise/shims/tool"), Some(Path::new(HOME))),
Durability::Unknown
);
}
#[test]
fn shim_without_home_is_unknown() {
assert_eq!(
judge_with_home(Path::new("/home/u/.local/share/mise/shims/node"), None),
Durability::Unknown
);
}
#[cfg(unix)]
#[test]
fn shim_anchored_under_home_is_durable() {
assert_eq!(
judge_with_home(Path::new("/home/u/.asdf/shims/ruby"), Some(Path::new(HOME))),
Durability::Durable
);
}
#[test]
fn project_local_cargo_bin_is_unknown() {
assert_eq!(
judge_with_home(Path::new("/repo/.cargo/bin/tool"), Some(Path::new(HOME))),
Durability::Unknown
);
}
#[test]
fn cargo_bin_without_home_is_unknown() {
assert_eq!(
judge_with_home(Path::new("/home/u/.cargo/bin/ripgrep"), None),
Durability::Unknown
);
}
#[test]
fn cargo_bin_nested_is_unknown() {
assert_eq!(
judge_with_home(
Path::new("/home/u/.cargo/bin/sub/tool"),
Some(Path::new(HOME))
),
Durability::Unknown
);
}
#[test]
fn cargo_binx_lookalike_is_unknown() {
assert_eq!(
judge_with_home(Path::new("/home/u/.cargo/binx/tool"), Some(Path::new(HOME))),
Durability::Unknown
);
}
#[test]
fn local_bin_jupyter_is_unknown() {
assert_eq!(judge_str("/home/u/.local/bin/jupyter"), Durability::Unknown);
}
#[test]
fn home_bin_is_unknown() {
assert_eq!(judge_str("/home/u/bin/mytool"), Durability::Unknown);
}
#[test]
fn unrecognized_dir_is_unknown() {
assert_eq!(judge_str("/some/random/dir/tool"), Durability::Unknown);
}
#[test]
fn ephemeral_shim_is_not_durable_despite_shim() {
assert_eq!(
judge_str("/home/u/.cache/jj-worktree/.mise/shims/git"),
Durability::NotDurable
);
}
#[test]
fn windows_chocolatey_lib_is_not_durable() {
assert_eq!(
judge_str(r"C:\ProgramData\chocolatey\lib\foo\bin\foo.exe"),
Durability::NotDurable
);
}
#[test]
fn windows_winget_packages_is_not_durable() {
assert_eq!(
judge_str(r"C:\Users\u\AppData\Local\Microsoft\WinGet\Packages\Foo\bin\foo.exe"),
Durability::NotDurable
);
}
#[test]
fn windows_scoop_apps_is_not_durable() {
assert_eq!(
judge_str(r"C:\Users\u\scoop\apps\foo\1.2.3\foo.exe"),
Durability::NotDurable
);
}
#[cfg(unix)]
#[test]
fn is_durable_direct_dir_exact_only() {
assert!(is_durable_direct_dir(&PathBuf::from("/usr/bin/git")));
assert!(!is_durable_direct_dir(&PathBuf::from("/usr/bin/sub/git")));
assert!(!is_durable_direct_dir(&PathBuf::from("usr/bin/git")));
}
}