use std::collections::HashSet;
use std::env;
use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use crate::durability::{self, Durability};
use crate::path_analysis;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
NotFound(PathBuf),
NotAFile(PathBuf),
NoFileName(PathBuf),
NotInPath(String),
Canonicalize(PathBuf, std::io::Error),
Metadata(PathBuf, std::io::Error),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::NotFound(p) => write!(f, "'{}' does not exist", p.display()),
Error::NotAFile(p) => write!(f, "'{}' is not a file", p.display()),
Error::NoFileName(p) => write!(f, "'{}' has no file name", p.display()),
Error::NotInPath(name) => write!(f, "'{}' not found in PATH", name),
Error::Canonicalize(p, e) => {
write!(f, "cannot canonicalize '{}': {}", p.display(), e)
}
Error::Metadata(p, e) => write!(f, "cannot stat '{}': {}", p.display(), e),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Canonicalize(_, e) | Error::Metadata(_, e) => Some(e),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum PathTag {
Input,
InPathEnv(usize),
SymlinkTo(PathBuf),
Shim,
SameCanonical,
SameContent,
Relative,
NonNormalized,
DifferentBinary,
ManagedBy(String),
BuildOutput,
Ephemeral,
NotExecutable,
}
#[derive(Debug, Clone)]
pub struct Candidate {
path: PathBuf,
canonical: PathBuf,
tags: Vec<PathTag>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum ScoringPolicy {
#[default]
SameBinary,
Stable,
}
impl Candidate {
pub fn path(&self) -> &Path {
&self.path
}
pub fn canonical(&self) -> &Path {
&self.canonical
}
pub fn tags(&self) -> &[PathTag] {
&self.tags
}
pub fn durability(&self) -> Durability {
durability::judge(&self.path)
}
pub fn is_stable(&self) -> bool {
matches!(self.durability(), Durability::Durable)
}
pub(crate) fn score(&self, policy: ScoringPolicy) -> i32 {
let binary_score = if self.tags.contains(&PathTag::SameCanonical) {
3
} else if self.tags.contains(&PathTag::SameContent) {
2
} else {
0
};
let has_build_output = self.tags.contains(&PathTag::BuildOutput);
let has_ephemeral = self.tags.contains(&PathTag::Ephemeral);
let has_managed = self.tags.iter().any(|t| matches!(t, PathTag::ManagedBy(_)));
let has_shim = self.tags.contains(&PathTag::Shim);
let preference_tier = if has_build_output || has_ephemeral {
0
} else if has_managed || has_shim {
1
} else {
3
};
let in_path_bonus = if self.tags.iter().any(|t| matches!(t, PathTag::InPathEnv(_))) {
5
} else {
0
};
let mut penalty = 0i32;
if self.tags.contains(&PathTag::NotExecutable) {
penalty -= 10000;
}
if self.tags.contains(&PathTag::Relative) {
penalty -= 3;
}
if self.tags.contains(&PathTag::NonNormalized) {
penalty -= 2;
}
match policy {
ScoringPolicy::SameBinary => {
binary_score * 1000 + preference_tier * 10 + in_path_bonus + penalty
}
ScoringPolicy::Stable => {
preference_tier * 1000 + binary_score * 10 + in_path_bonus + penalty
}
}
}
pub(crate) fn path_order(&self) -> usize {
self.tags
.iter()
.find_map(|t| match t {
PathTag::InPathEnv(order) => Some(*order),
_ => None,
})
.unwrap_or(usize::MAX)
}
fn new(path: PathBuf, canonical: PathBuf, tags: Vec<PathTag>) -> Self {
Candidate {
path,
canonical,
tags,
}
}
}
#[cfg(test)]
impl Candidate {
pub(crate) fn for_test(path: PathBuf, canonical: PathBuf, tags: Vec<PathTag>) -> Self {
Candidate::new(path, canonical, tags)
}
}
fn normalize_for_dedup(path: &Path) -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(path.to_string_lossy().to_lowercase())
}
#[cfg(not(windows))]
{
path.to_path_buf()
}
}
fn normalize_path(path: &Path) -> PathBuf {
use std::path::Component;
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
result.pop();
}
other => result.push(other),
}
}
result
}
fn tag_path(path: &Path, input_canonical: &Path, input_path: &Path) -> (Vec<PathTag>, PathBuf) {
let mut tags = Vec::new();
if !path.is_absolute() {
tags.push(PathTag::Relative);
}
let path_str = path.to_string_lossy();
if path_str.contains("/./")
|| path_str.contains("/../")
|| path_str.contains("\\.\\")
|| path_str.contains("\\..\\")
|| path.components().any(|c| {
matches!(
c,
std::path::Component::CurDir | std::path::Component::ParentDir
)
})
{
tags.push(PathTag::NonNormalized);
}
let symlink_target = fs::read_link(path).ok();
if let Some(ref target) = symlink_target {
tags.push(PathTag::SymlinkTo(target.clone()));
}
if path_analysis::is_shim_path(path) {
tags.push(PathTag::Shim);
} else if let Some(ref target) = symlink_target {
let candidate_name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let target_name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if !candidate_name.is_empty()
&& !target_name.is_empty()
&& path_analysis::is_shim_by_name(&candidate_name, &target_name)
{
tags.push(PathTag::Shim);
}
}
let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if canonical == input_canonical {
tags.push(PathTag::SameCanonical);
} else if path_analysis::files_have_same_content(path, input_path) {
tags.push(PathTag::SameContent);
} else {
tags.push(PathTag::DifferentBinary);
}
if let Some(info) = path_analysis::detect_version_manager(path) {
tags.push(PathTag::ManagedBy(info.name));
}
if path_analysis::is_build_output(path) {
tags.push(PathTag::BuildOutput);
}
if path_analysis::is_ephemeral(path) {
tags.push(PathTag::Ephemeral);
}
if !path_analysis::is_executable(path) {
tags.push(PathTag::NotExecutable);
} else if !path.is_absolute() {
let path_str = path.to_string_lossy();
if !path_str.starts_with("./")
&& !path_str.starts_with("../")
&& !path_str.starts_with(".\\")
&& !path_str.starts_with("..\\")
{
tags.push(PathTag::NotExecutable);
}
}
(tags, canonical)
}
pub fn find_candidates_with_path_env(
binary: &Path,
path_env: Option<OsString>,
) -> Result<Vec<Candidate>, Error> {
let resolved_binary;
let binary = if !binary.to_string_lossy().contains(std::path::MAIN_SEPARATOR)
&& !binary.to_string_lossy().contains('/')
{
let name = binary
.file_name()
.ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
resolved_binary = path_env
.as_ref()
.and_then(|pv| {
env::split_paths(pv).find_map(|dir| {
let exact = dir.join(name);
if path_analysis::is_executable(&exact) {
return Some(exact);
}
#[cfg(windows)]
{
let pathext = std::env::var("PATHEXT")
.unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
for ext in pathext.split(';') {
let with_ext = dir.join(format!(
"{}{}",
name.to_string_lossy(),
ext.to_lowercase()
));
if path_analysis::is_executable(&with_ext) {
return Some(with_ext);
}
}
}
None
})
})
.ok_or_else(|| Error::NotInPath(binary.display().to_string()))?;
resolved_binary.as_path()
} else {
binary
};
if !binary.exists() {
return Err(Error::NotFound(binary.to_path_buf()));
}
let metadata = fs::metadata(binary).map_err(|e| Error::Metadata(binary.to_path_buf(), e))?;
if !metadata.is_file() {
return Err(Error::NotAFile(binary.to_path_buf()));
}
let input_canonical =
fs::canonicalize(binary).map_err(|e| Error::Canonicalize(binary.to_path_buf(), e))?;
let file_name = binary
.file_name()
.ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
let mut candidates = Vec::new();
let mut path_order: usize = 0;
let mut seen_paths = HashSet::new();
{
let (mut tags, canonical) = tag_path(binary, &input_canonical, binary);
tags.insert(0, PathTag::Input);
seen_paths.insert(normalize_for_dedup(binary));
candidates.push(Candidate::new(binary.to_path_buf(), canonical, tags));
}
if !binary.is_absolute() {
let path_str = binary.to_string_lossy();
let has_explicit_prefix = path_str.starts_with("./")
|| path_str.starts_with("../")
|| path_str.starts_with(".\\")
|| path_str.starts_with("..\\");
let has_separator = path_str.contains('/') || path_str.contains('\\');
if !has_explicit_prefix && has_separator {
let prefix = if cfg!(windows) { ".\\" } else { "./" };
let dot_prefixed = PathBuf::from(format!("{prefix}{path_str}"));
let dedup_key = normalize_for_dedup(&dot_prefixed);
if dot_prefixed.exists() && !seen_paths.contains(&dedup_key) {
seen_paths.insert(dedup_key);
let (tags, canonical) = tag_path(&dot_prefixed, &input_canonical, binary);
candidates.push(Candidate::new(dot_prefixed, canonical, tags));
}
}
}
if !binary.is_absolute() {
if let Ok(cwd) = env::current_dir() {
let absolute = normalize_path(&cwd.join(binary));
let dedup_key = normalize_for_dedup(&absolute);
if !seen_paths.contains(&dedup_key) {
seen_paths.insert(dedup_key);
let (tags, canonical) = tag_path(&absolute, &input_canonical, binary);
candidates.push(Candidate::new(absolute, canonical, tags));
}
}
}
{
let dedup_key = normalize_for_dedup(&input_canonical);
if !seen_paths.contains(&dedup_key) {
seen_paths.insert(dedup_key);
let (tags, canonical) = tag_path(&input_canonical, &input_canonical, binary);
candidates.push(Candidate::new(input_canonical.clone(), canonical, tags));
}
}
if let Ok(link_target) = fs::read_link(binary) {
let link_target = if link_target.is_relative() {
let joined = binary
.parent()
.map(|p| p.join(&link_target))
.unwrap_or(link_target);
normalize_path(&joined)
} else {
link_target
};
let dedup_key = normalize_for_dedup(&link_target);
if !seen_paths.contains(&dedup_key) {
seen_paths.insert(dedup_key);
let (tags, canonical) = tag_path(&link_target, &input_canonical, binary);
candidates.push(Candidate::new(link_target, canonical, tags));
}
}
if let Some(ref path_var) = path_env {
for dir in env::split_paths(path_var) {
#[allow(unused_mut)]
let mut candidates_in_dir = vec![dir.join(file_name)];
#[cfg(windows)]
{
let pathext =
std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
let name_str = file_name.to_string_lossy();
for ext in pathext.split(';') {
candidates_in_dir.push(dir.join(format!("{}{}", name_str, ext.to_lowercase())));
}
}
for candidate_path in candidates_in_dir {
if candidate_path == binary {
continue;
}
if !path_analysis::is_executable(&candidate_path) {
continue;
}
if !seen_paths.insert(normalize_for_dedup(&candidate_path)) {
continue;
}
let (mut tags, canonical) = tag_path(&candidate_path, &input_canonical, binary);
tags.insert(0, PathTag::InPathEnv(path_order));
candidates.push(Candidate::new(candidate_path, canonical, tags));
path_order += 1;
}
}
}
Ok(candidates)
}
pub fn find_candidates(binary: &Path) -> Result<Vec<Candidate>, Error> {
find_candidates_with_path_env(binary, env::var_os("PATH"))
}
pub fn rank_candidates(candidates: &mut [Candidate], policy: ScoringPolicy) {
candidates.sort_by(|a, b| {
let score_cmp = b.score(policy).cmp(&a.score(policy));
score_cmp.then(a.path_order().cmp(&b.path_order()))
});
}
pub fn resolve_stable_path(binary: &Path, policy: ScoringPolicy) -> Result<Candidate, Error> {
let mut candidates = find_candidates(binary)?;
rank_candidates(&mut candidates, policy);
Ok(candidates
.into_iter()
.next()
.expect("find_candidates returns a non-empty Vec on success"))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
fn make_candidate(tags: Vec<PathTag>) -> Candidate {
Candidate::for_test(PathBuf::from("/dummy"), PathBuf::from("/dummy"), tags)
}
#[test]
fn test_score_same_binary_policy_highest() {
let c = make_candidate(vec![PathTag::SameCanonical, PathTag::InPathEnv(0)]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3035);
}
#[test]
fn test_score_same_content_in_path() {
let c = make_candidate(vec![PathTag::SameContent, PathTag::InPathEnv(0)]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 2035);
}
#[test]
fn test_score_different_binary() {
let c = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 35);
}
#[test]
fn test_score_stable_policy_stable_path_wins() {
let stable_different =
make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
let unstable_same = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::BuildOutput,
]);
let stable_score = stable_different.score(ScoringPolicy::Stable);
let unstable_score = unstable_same.score(ScoringPolicy::Stable);
assert_eq!(stable_score, 3005);
assert_eq!(unstable_score, 35);
assert!(stable_score > unstable_score);
}
#[test]
fn test_score_relative_penalty() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::Relative,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3032);
}
#[test]
fn test_score_non_normalized_penalty() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::NonNormalized,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3033);
}
#[test]
fn test_score_both_penalties_accumulate() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::Relative,
PathTag::NonNormalized,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3030);
}
#[test]
fn test_score_build_output_zero_stability() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::BuildOutput,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3005);
}
#[test]
fn test_score_ephemeral_zero_stability() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::Ephemeral,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3005);
}
#[test]
fn test_score_managed_by_stability() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::ManagedBy("mise".to_string()),
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3015);
}
#[test]
fn test_score_shim_stability() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::Shim,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3015);
}
#[test]
fn test_score_managed_and_shim_both_present() {
let c = make_candidate(vec![
PathTag::SameCanonical,
PathTag::InPathEnv(0),
PathTag::ManagedBy("mise".to_string()),
PathTag::Shim,
]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3015);
}
#[test]
fn test_score_no_in_path_bonus() {
let c = make_candidate(vec![PathTag::SameCanonical]);
let score = c.score(ScoringPolicy::SameBinary);
assert_eq!(score, 3030);
}
#[cfg(unix)]
struct TestFixture {
_tmpdir: tempfile::TempDir,
real_binary: PathBuf,
stable_link: PathBuf,
stable_dir: PathBuf,
other_binary: PathBuf,
other_dir: PathBuf,
}
#[cfg(unix)]
impl TestFixture {
fn new() -> Self {
let tmpdir = tempfile::tempdir().unwrap();
let base = tmpdir.path();
let real_dir = base.join("real");
let stable_dir = base.join("stable_dir");
let other_dir = base.join("other_dir");
fs::create_dir_all(&real_dir).unwrap();
fs::create_dir_all(&stable_dir).unwrap();
fs::create_dir_all(&other_dir).unwrap();
let real_binary = real_dir.join("mybinary");
fs::write(&real_binary, "real-content").unwrap();
fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
let stable_link = stable_dir.join("mybinary");
symlink(&real_binary, &stable_link).unwrap();
let other_binary = other_dir.join("mybinary");
fs::write(&other_binary, "other-content").unwrap();
fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
TestFixture {
_tmpdir: tmpdir,
real_binary,
stable_link,
stable_dir,
other_binary,
other_dir,
}
}
fn make_path(&self, dirs: &[&Path]) -> OsString {
env::join_paths(dirs).unwrap()
}
}
#[cfg(unix)]
#[test]
fn test_symlink_same_canonical() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
assert!(candidates.len() >= 2);
let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
assert!(symlink_cand.tags.contains(&PathTag::SameCanonical));
assert!(
symlink_cand
.tags
.iter()
.any(|t| matches!(t, PathTag::InPathEnv(_)))
);
assert!(
symlink_cand
.tags
.iter()
.any(|t| matches!(t, PathTag::SymlinkTo(_)))
);
}
#[cfg(unix)]
#[test]
fn test_different_binary_tagged() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.other_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let other_cand = candidates
.iter()
.find(|c| c.path == f.other_binary)
.unwrap();
assert!(other_cand.tags.contains(&PathTag::DifferentBinary));
}
#[cfg(unix)]
#[test]
fn test_no_path_matches_returns_input_only() {
let f = TestFixture::new();
let empty_dir = f._tmpdir.path().join("empty");
fs::create_dir_all(&empty_dir).unwrap();
let path_env = f.make_path(&[&empty_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
assert!(!candidates.is_empty());
assert!(candidates.iter().any(|c| c.tags.contains(&PathTag::Input)));
}
#[cfg(unix)]
#[test]
fn test_input_tag_present() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
assert!(input_cand.tags.contains(&PathTag::Input));
}
#[cfg(unix)]
#[test]
fn test_in_path_env_tag_present() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let path_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
assert!(
path_cand
.tags
.iter()
.any(|t| matches!(t, PathTag::InPathEnv(_)))
);
let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
assert!(
!input_cand
.tags
.iter()
.any(|t| matches!(t, PathTag::InPathEnv(_)))
);
}
#[cfg(unix)]
#[test]
fn test_symlink_to_tag_present() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
let has_symlink_tag = symlink_cand
.tags
.iter()
.any(|t| matches!(t, PathTag::SymlinkTo(_)));
assert!(has_symlink_tag);
}
#[cfg(unix)]
#[test]
fn test_same_content_detected() {
let tmpdir = tempfile::tempdir().unwrap();
let base = tmpdir.path();
let dir_a = base.join("a");
let dir_b = base.join("b");
fs::create_dir_all(&dir_a).unwrap();
fs::create_dir_all(&dir_b).unwrap();
let binary_a = dir_a.join("mybin");
let binary_b = dir_b.join("mybin");
fs::write(&binary_a, "identical-content").unwrap();
fs::set_permissions(&binary_a, fs::Permissions::from_mode(0o755)).unwrap();
fs::write(&binary_b, "identical-content").unwrap();
fs::set_permissions(&binary_b, fs::Permissions::from_mode(0o755)).unwrap();
let path_env = env::join_paths([&dir_b]).unwrap();
let candidates = find_candidates_with_path_env(&binary_a, Some(path_env)).unwrap();
let copy_cand = candidates.iter().find(|c| c.path == binary_b).unwrap();
assert!(copy_cand.tags.contains(&PathTag::SameContent));
assert!(!copy_cand.tags.contains(&PathTag::SameCanonical));
}
#[cfg(unix)]
#[test]
fn test_rank_candidates_sorts_by_score_descending() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
let mut candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
rank_candidates(&mut candidates, ScoringPolicy::SameBinary);
let scores: Vec<i32> = candidates
.iter()
.map(|c| c.score(ScoringPolicy::SameBinary))
.collect();
for w in scores.windows(2) {
assert!(w[0] >= w[1], "scores not descending: {:?}", scores);
}
}
#[test]
fn test_nonexistent_binary_error() {
let result = find_candidates_with_path_env(Path::new("/nonexistent/binary"), None);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn test_duplicate_input_path_skipped() {
let f = TestFixture::new();
let real_dir = f.real_binary.parent().unwrap();
let path_env = f.make_path(&[real_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let count = candidates
.iter()
.filter(|c| c.path == f.real_binary)
.count();
assert_eq!(count, 1, "input path should appear exactly once");
}
#[cfg(unix)]
#[test]
fn test_duplicate_path_directory_deduped() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir, &f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
let stable_count = candidates
.iter()
.filter(|c| c.path == f.stable_link)
.count();
assert_eq!(
stable_count, 1,
"duplicate PATH directory should be deduped"
);
}
#[cfg(unix)]
#[test]
fn test_command_name_lookup() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.stable_dir]);
let candidates =
find_candidates_with_path_env(Path::new("mybinary"), Some(path_env)).unwrap();
assert!(!candidates.is_empty());
assert!(candidates[0].tags.contains(&PathTag::Input));
}
#[test]
fn test_command_name_not_found() {
let tmpdir = tempfile::tempdir().unwrap();
let empty_dir = tmpdir.path().join("empty");
fs::create_dir_all(&empty_dir).unwrap();
let path_env = env::join_paths([&empty_dir]).unwrap();
let result = find_candidates_with_path_env(Path::new("nonexistent"), Some(path_env));
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Error::NotInPath(_)));
}
#[test]
fn test_stable_policy_prefers_stable_different_binary_over_unstable_same() {
let stable = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
let unstable = make_candidate(vec![PathTag::SameCanonical, PathTag::BuildOutput]);
assert!(stable.score(ScoringPolicy::Stable) > unstable.score(ScoringPolicy::Stable));
assert!(
unstable.score(ScoringPolicy::SameBinary) > stable.score(ScoringPolicy::SameBinary)
);
}
#[cfg(unix)]
#[test]
fn test_shim_directory_detected() {
let tmpdir = tempfile::tempdir().unwrap();
let shim_dir = tmpdir.path().join(".mise").join("shims");
fs::create_dir_all(&shim_dir).unwrap();
let shim_binary = shim_dir.join("mybin");
fs::write(&shim_binary, "shim-content").unwrap();
fs::set_permissions(&shim_binary, fs::Permissions::from_mode(0o755)).unwrap();
let other_dir = tmpdir.path().join("other");
fs::create_dir_all(&other_dir).unwrap();
let other_binary = other_dir.join("mybin");
fs::write(&other_binary, "other-content").unwrap();
fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
let path_env = env::join_paths([&shim_dir, &other_dir]).unwrap();
let candidates = find_candidates_with_path_env(&other_binary, Some(path_env)).unwrap();
let shim_cand = candidates.iter().find(|c| c.path == shim_binary).unwrap();
assert!(shim_cand.tags.contains(&PathTag::Shim));
}
#[cfg(unix)]
#[test]
fn test_shim_by_symlink_name_mismatch() {
let tmpdir = tempfile::tempdir().unwrap();
let base = tmpdir.path();
let real_dir = base.join("real");
let shim_dir = base.join("bin");
fs::create_dir_all(&real_dir).unwrap();
fs::create_dir_all(&shim_dir).unwrap();
let real_binary = real_dir.join("jj-worktree");
fs::write(&real_binary, "binary-content").unwrap();
fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
let shim_link = shim_dir.join("git");
symlink(&real_binary, &shim_link).unwrap();
let input_dir = base.join("input");
fs::create_dir_all(&input_dir).unwrap();
let input_binary = input_dir.join("git");
fs::write(&input_binary, "real-git-content").unwrap();
fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
let path_env = env::join_paths([&shim_dir]).unwrap();
let candidates = find_candidates_with_path_env(&input_binary, Some(path_env)).unwrap();
let shim_cand = candidates.iter().find(|c| c.path == shim_link).unwrap();
assert!(shim_cand.tags.contains(&PathTag::Shim));
}
#[cfg(unix)]
#[test]
fn test_version_suffix_symlink_not_shim() {
let tmpdir = tempfile::tempdir().unwrap();
let base = tmpdir.path();
let real_dir = base.join("real");
let link_dir = base.join("bin");
fs::create_dir_all(&real_dir).unwrap();
fs::create_dir_all(&link_dir).unwrap();
let real_binary = real_dir.join("python3.12");
fs::write(&real_binary, "python-content").unwrap();
fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
let link = link_dir.join("python3");
symlink(&real_binary, &link).unwrap();
let input_dir = base.join("input");
fs::create_dir_all(&input_dir).unwrap();
let input_binary = input_dir.join("python3");
fs::write(&input_binary, "python-content").unwrap();
fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
let path_env = env::join_paths([&link_dir]).unwrap();
let candidates = find_candidates_with_path_env(&input_binary, Some(path_env)).unwrap();
let link_cand = candidates.iter().find(|c| c.path == link).unwrap();
assert!(!link_cand.tags.contains(&PathTag::Shim));
}
#[cfg(unix)]
#[test]
fn test_durability_accessor_per_candidate() {
let durable = Candidate::for_test(
PathBuf::from("/opt/homebrew/bin/git"),
PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
vec![PathTag::Input],
);
assert_eq!(durable.durability(), Durability::Durable);
assert!(durable.is_stable());
let versioned = Candidate::for_test(
PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
vec![PathTag::SameCanonical],
);
assert_eq!(versioned.durability(), Durability::NotDurable);
assert!(!versioned.is_stable());
}
#[test]
fn test_is_stable_false_for_unknown() {
let c = Candidate::for_test(
PathBuf::from("/home/u/.local/bin/jupyter"),
PathBuf::from("/home/u/.local/bin/jupyter"),
vec![PathTag::Input],
);
assert_eq!(c.durability(), Durability::Unknown);
assert!(!c.is_stable());
}
#[test]
fn test_accessors_return_internal_fields() {
let c = Candidate::for_test(
PathBuf::from("/a/b"),
PathBuf::from("/c/d"),
vec![PathTag::Input, PathTag::SameCanonical],
);
assert_eq!(c.path(), Path::new("/a/b"));
assert_eq!(c.canonical(), Path::new("/c/d"));
assert_eq!(c.tags(), &[PathTag::Input, PathTag::SameCanonical]);
}
#[cfg(unix)]
#[test]
fn test_find_candidates_with_path_env_is_unsorted_discovery() {
let f = TestFixture::new();
let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
assert!(candidates[0].tags().contains(&PathTag::Input));
}
#[cfg(unix)]
#[test]
fn test_find_candidates_non_empty_on_success() {
let f = TestFixture::new();
let candidates = find_candidates_with_path_env(&f.real_binary, None).unwrap();
assert!(!candidates.is_empty());
assert!(
candidates
.iter()
.any(|c| c.tags().contains(&PathTag::Input))
);
}
#[cfg(unix)]
#[test]
fn test_resolve_stable_path_returns_tagged_candidate() {
let f = TestFixture::new();
let best = resolve_stable_path(&f.real_binary, ScoringPolicy::SameBinary).unwrap();
assert!(!best.tags().is_empty());
}
}