1use std::collections::HashSet;
2use std::env;
3use std::ffi::OsString;
4use std::fmt;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8use crate::durability::{self, Durability};
9use crate::path_analysis;
10
11#[derive(Debug)]
13#[non_exhaustive]
14pub enum Error {
15 NotFound(PathBuf),
17 NotAFile(PathBuf),
19 NoFileName(PathBuf),
21 NotInPath(String),
23 Canonicalize(PathBuf, std::io::Error),
25 Metadata(PathBuf, std::io::Error),
27}
28
29impl fmt::Display for Error {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 match self {
32 Error::NotFound(p) => write!(f, "'{}' does not exist", p.display()),
33 Error::NotAFile(p) => write!(f, "'{}' is not a file", p.display()),
34 Error::NoFileName(p) => write!(f, "'{}' has no file name", p.display()),
35 Error::NotInPath(name) => write!(f, "'{}' not found in PATH", name),
36 Error::Canonicalize(p, e) => {
37 write!(f, "cannot canonicalize '{}': {}", p.display(), e)
38 }
39 Error::Metadata(p, e) => write!(f, "cannot stat '{}': {}", p.display(), e),
40 }
41 }
42}
43
44impl std::error::Error for Error {
45 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
46 match self {
47 Error::Canonicalize(_, e) | Error::Metadata(_, e) => Some(e),
48 _ => None,
49 }
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum PathTag {
60 Input,
62 InPathEnv(usize),
64 SymlinkTo(PathBuf),
66 Shim,
68 SameCanonical,
70 SameContent,
72 Relative,
74 NonNormalized,
76 DifferentBinary,
78 ManagedBy(String),
80 BuildOutput,
82 Ephemeral,
84 NotExecutable,
86}
87
88#[derive(Debug, Clone)]
97pub struct Candidate {
98 path: PathBuf,
99 canonical: PathBuf,
100 tags: Vec<PathTag>,
101}
102
103#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum ScoringPolicy {
110 #[default]
116 SameBinary,
117 Stable,
123}
124
125impl Candidate {
126 pub fn path(&self) -> &Path {
129 &self.path
130 }
131
132 pub fn canonical(&self) -> &Path {
138 &self.canonical
139 }
140
141 pub fn tags(&self) -> &[PathTag] {
148 &self.tags
149 }
150
151 pub fn durability(&self) -> Durability {
162 durability::judge(&self.path)
163 }
164
165 pub fn is_stable(&self) -> bool {
170 matches!(self.durability(), Durability::Durable)
171 }
172
173 pub(crate) fn score(&self, policy: ScoringPolicy) -> i32 {
178 let binary_score = if self.tags.contains(&PathTag::SameCanonical) {
179 3
180 } else if self.tags.contains(&PathTag::SameContent) {
181 2
182 } else {
183 0
184 };
185
186 let has_build_output = self.tags.contains(&PathTag::BuildOutput);
187 let has_ephemeral = self.tags.contains(&PathTag::Ephemeral);
188 let has_managed = self.tags.iter().any(|t| matches!(t, PathTag::ManagedBy(_)));
189 let has_shim = self.tags.contains(&PathTag::Shim);
190
191 let preference_tier = if has_build_output || has_ephemeral {
199 0
200 } else if has_managed || has_shim {
201 1
202 } else {
203 3
204 };
205
206 let in_path_bonus = if self.tags.iter().any(|t| matches!(t, PathTag::InPathEnv(_))) {
207 5
208 } else {
209 0
210 };
211
212 let mut penalty = 0i32;
213 if self.tags.contains(&PathTag::NotExecutable) {
214 penalty -= 10000;
215 }
216 if self.tags.contains(&PathTag::Relative) {
217 penalty -= 3;
218 }
219 if self.tags.contains(&PathTag::NonNormalized) {
220 penalty -= 2;
221 }
222
223 match policy {
224 ScoringPolicy::SameBinary => {
225 binary_score * 1000 + preference_tier * 10 + in_path_bonus + penalty
226 }
227 ScoringPolicy::Stable => {
228 preference_tier * 1000 + binary_score * 10 + in_path_bonus + penalty
229 }
230 }
231 }
232
233 pub(crate) fn path_order(&self) -> usize {
237 self.tags
238 .iter()
239 .find_map(|t| match t {
240 PathTag::InPathEnv(order) => Some(*order),
241 _ => None,
242 })
243 .unwrap_or(usize::MAX)
244 }
245
246 fn new(path: PathBuf, canonical: PathBuf, tags: Vec<PathTag>) -> Self {
248 Candidate {
249 path,
250 canonical,
251 tags,
252 }
253 }
254}
255
256#[cfg(test)]
257impl Candidate {
258 pub(crate) fn for_test(path: PathBuf, canonical: PathBuf, tags: Vec<PathTag>) -> Self {
260 Candidate::new(path, canonical, tags)
261 }
262}
263
264fn normalize_for_dedup(path: &Path) -> PathBuf {
266 #[cfg(windows)]
267 {
268 PathBuf::from(path.to_string_lossy().to_lowercase())
269 }
270 #[cfg(not(windows))]
271 {
272 path.to_path_buf()
273 }
274}
275
276fn normalize_path(path: &Path) -> PathBuf {
278 use std::path::Component;
279 let mut result = PathBuf::new();
280 for component in path.components() {
281 match component {
282 Component::CurDir => {}
283 Component::ParentDir => {
284 result.pop();
285 }
286 other => result.push(other),
287 }
288 }
289 result
290}
291
292fn tag_path(path: &Path, input_canonical: &Path, input_path: &Path) -> (Vec<PathTag>, PathBuf) {
293 let mut tags = Vec::new();
294
295 if !path.is_absolute() {
297 tags.push(PathTag::Relative);
298 }
299
300 let path_str = path.to_string_lossy();
302 if path_str.contains("/./")
303 || path_str.contains("/../")
304 || path_str.contains("\\.\\")
305 || path_str.contains("\\..\\")
306 || path.components().any(|c| {
307 matches!(
308 c,
309 std::path::Component::CurDir | std::path::Component::ParentDir
310 )
311 })
312 {
313 tags.push(PathTag::NonNormalized);
314 }
315
316 let symlink_target = fs::read_link(path).ok();
318 if let Some(ref target) = symlink_target {
319 tags.push(PathTag::SymlinkTo(target.clone()));
320 }
321
322 if path_analysis::is_shim_path(path) {
324 tags.push(PathTag::Shim);
325 } else if let Some(ref target) = symlink_target {
326 let candidate_name = path
327 .file_name()
328 .map(|n| n.to_string_lossy().into_owned())
329 .unwrap_or_default();
330 let target_name = target
331 .file_name()
332 .map(|n| n.to_string_lossy().into_owned())
333 .unwrap_or_default();
334 if !candidate_name.is_empty()
335 && !target_name.is_empty()
336 && path_analysis::is_shim_by_name(&candidate_name, &target_name)
337 {
338 tags.push(PathTag::Shim);
339 }
340 }
341
342 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
344
345 if canonical == input_canonical {
347 tags.push(PathTag::SameCanonical);
348 } else if path_analysis::files_have_same_content(path, input_path) {
349 tags.push(PathTag::SameContent);
350 } else {
351 tags.push(PathTag::DifferentBinary);
352 }
353
354 if let Some(info) = path_analysis::detect_version_manager(path) {
356 tags.push(PathTag::ManagedBy(info.name));
357 }
358
359 if path_analysis::is_build_output(path) {
361 tags.push(PathTag::BuildOutput);
362 }
363
364 if path_analysis::is_ephemeral(path) {
366 tags.push(PathTag::Ephemeral);
367 }
368
369 if !path_analysis::is_executable(path) {
371 tags.push(PathTag::NotExecutable);
372 } else if !path.is_absolute() {
373 let path_str = path.to_string_lossy();
374 if !path_str.starts_with("./")
375 && !path_str.starts_with("../")
376 && !path_str.starts_with(".\\")
377 && !path_str.starts_with("..\\")
378 {
379 tags.push(PathTag::NotExecutable);
380 }
381 }
382
383 (tags, canonical)
384}
385
386pub fn find_candidates_with_path_env(
411 binary: &Path,
412 path_env: Option<OsString>,
413) -> Result<Vec<Candidate>, Error> {
414 let resolved_binary;
416 let binary = if !binary.to_string_lossy().contains(std::path::MAIN_SEPARATOR)
417 && !binary.to_string_lossy().contains('/')
418 {
419 let name = binary
420 .file_name()
421 .ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
422 resolved_binary = path_env
423 .as_ref()
424 .and_then(|pv| {
425 env::split_paths(pv).find_map(|dir| {
426 let exact = dir.join(name);
428 if path_analysis::is_executable(&exact) {
429 return Some(exact);
430 }
431 #[cfg(windows)]
438 {
439 let pathext = std::env::var("PATHEXT")
440 .unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
441 for ext in pathext.split(';') {
442 let with_ext = dir.join(format!(
443 "{}{}",
444 name.to_string_lossy(),
445 ext.to_lowercase()
446 ));
447 if path_analysis::is_executable(&with_ext) {
448 return Some(with_ext);
449 }
450 }
451 }
452 None
453 })
454 })
455 .ok_or_else(|| Error::NotInPath(binary.display().to_string()))?;
456 resolved_binary.as_path()
457 } else {
458 binary
459 };
460
461 if !binary.exists() {
463 return Err(Error::NotFound(binary.to_path_buf()));
464 }
465 let metadata = fs::metadata(binary).map_err(|e| Error::Metadata(binary.to_path_buf(), e))?;
466 if !metadata.is_file() {
467 return Err(Error::NotAFile(binary.to_path_buf()));
468 }
469
470 let input_canonical =
472 fs::canonicalize(binary).map_err(|e| Error::Canonicalize(binary.to_path_buf(), e))?;
473
474 let file_name = binary
475 .file_name()
476 .ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
477
478 let mut candidates = Vec::new();
479 let mut path_order: usize = 0;
480 let mut seen_paths = HashSet::new();
481
482 {
485 let (mut tags, canonical) = tag_path(binary, &input_canonical, binary);
486 tags.insert(0, PathTag::Input);
487 seen_paths.insert(normalize_for_dedup(binary));
488 candidates.push(Candidate::new(binary.to_path_buf(), canonical, tags));
489 }
490
491 if !binary.is_absolute() {
493 let path_str = binary.to_string_lossy();
494 let has_explicit_prefix = path_str.starts_with("./")
495 || path_str.starts_with("../")
496 || path_str.starts_with(".\\")
497 || path_str.starts_with("..\\");
498 let has_separator = path_str.contains('/') || path_str.contains('\\');
499 if !has_explicit_prefix && has_separator {
500 let prefix = if cfg!(windows) { ".\\" } else { "./" };
501 let dot_prefixed = PathBuf::from(format!("{prefix}{path_str}"));
502 let dedup_key = normalize_for_dedup(&dot_prefixed);
503 if dot_prefixed.exists() && !seen_paths.contains(&dedup_key) {
504 seen_paths.insert(dedup_key);
505 let (tags, canonical) = tag_path(&dot_prefixed, &input_canonical, binary);
506 candidates.push(Candidate::new(dot_prefixed, canonical, tags));
507 }
508 }
509 }
510
511 if !binary.is_absolute() {
513 if let Ok(cwd) = env::current_dir() {
514 let absolute = normalize_path(&cwd.join(binary));
515 let dedup_key = normalize_for_dedup(&absolute);
516 if !seen_paths.contains(&dedup_key) {
517 seen_paths.insert(dedup_key);
518 let (tags, canonical) = tag_path(&absolute, &input_canonical, binary);
519 candidates.push(Candidate::new(absolute, canonical, tags));
520 }
521 }
522 }
523
524 {
526 let dedup_key = normalize_for_dedup(&input_canonical);
527 if !seen_paths.contains(&dedup_key) {
528 seen_paths.insert(dedup_key);
529 let (tags, canonical) = tag_path(&input_canonical, &input_canonical, binary);
530 candidates.push(Candidate::new(input_canonical.clone(), canonical, tags));
531 }
532 }
533
534 if let Ok(link_target) = fs::read_link(binary) {
536 let link_target = if link_target.is_relative() {
537 let joined = binary
543 .parent()
544 .map(|p| p.join(&link_target))
545 .unwrap_or(link_target);
546 normalize_path(&joined)
547 } else {
548 link_target
549 };
550 let dedup_key = normalize_for_dedup(&link_target);
551 if !seen_paths.contains(&dedup_key) {
552 seen_paths.insert(dedup_key);
553 let (tags, canonical) = tag_path(&link_target, &input_canonical, binary);
554 candidates.push(Candidate::new(link_target, canonical, tags));
555 }
556 }
557
558 if let Some(ref path_var) = path_env {
560 for dir in env::split_paths(path_var) {
561 #[allow(unused_mut)]
562 let mut candidates_in_dir = vec![dir.join(file_name)];
563
564 #[cfg(windows)]
566 {
567 let pathext =
568 std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
569 let name_str = file_name.to_string_lossy();
570 for ext in pathext.split(';') {
571 candidates_in_dir.push(dir.join(format!("{}{}", name_str, ext.to_lowercase())));
572 }
573 }
574
575 for candidate_path in candidates_in_dir {
576 if candidate_path == binary {
577 continue;
578 }
579 if !path_analysis::is_executable(&candidate_path) {
580 continue;
581 }
582 if !seen_paths.insert(normalize_for_dedup(&candidate_path)) {
584 continue;
585 }
586 let (mut tags, canonical) = tag_path(&candidate_path, &input_canonical, binary);
587 tags.insert(0, PathTag::InPathEnv(path_order));
588 candidates.push(Candidate::new(candidate_path, canonical, tags));
589 path_order += 1;
590 }
591 }
592 }
593
594 Ok(candidates)
595}
596
597pub fn find_candidates(binary: &Path) -> Result<Vec<Candidate>, Error> {
607 find_candidates_with_path_env(binary, env::var_os("PATH"))
608}
609
610pub fn rank_candidates(candidates: &mut [Candidate], policy: ScoringPolicy) {
617 candidates.sort_by(|a, b| {
618 let score_cmp = b.score(policy).cmp(&a.score(policy));
619 score_cmp.then(a.path_order().cmp(&b.path_order()))
620 });
621}
622
623pub fn resolve_stable_path(binary: &Path, policy: ScoringPolicy) -> Result<Candidate, Error> {
630 let mut candidates = find_candidates(binary)?;
631 rank_candidates(&mut candidates, policy);
632 Ok(candidates
635 .into_iter()
636 .next()
637 .expect("find_candidates returns a non-empty Vec on success"))
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643 #[cfg(unix)]
644 use std::os::unix::fs::{PermissionsExt, symlink};
645
646 fn make_candidate(tags: Vec<PathTag>) -> Candidate {
649 Candidate::for_test(PathBuf::from("/dummy"), PathBuf::from("/dummy"), tags)
650 }
651
652 #[test]
653 fn test_score_same_binary_policy_highest() {
654 let c = make_candidate(vec![PathTag::SameCanonical, PathTag::InPathEnv(0)]);
656 let score = c.score(ScoringPolicy::SameBinary);
657 assert_eq!(score, 3035);
659 }
660
661 #[test]
662 fn test_score_same_content_in_path() {
663 let c = make_candidate(vec![PathTag::SameContent, PathTag::InPathEnv(0)]);
664 let score = c.score(ScoringPolicy::SameBinary);
665 assert_eq!(score, 2035);
667 }
668
669 #[test]
670 fn test_score_different_binary() {
671 let c = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
672 let score = c.score(ScoringPolicy::SameBinary);
673 assert_eq!(score, 35);
675 }
676
677 #[test]
678 fn test_score_stable_policy_stable_path_wins() {
679 let stable_different =
681 make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
682 let unstable_same = make_candidate(vec![
683 PathTag::SameCanonical,
684 PathTag::InPathEnv(0),
685 PathTag::BuildOutput,
686 ]);
687 let stable_score = stable_different.score(ScoringPolicy::Stable);
688 let unstable_score = unstable_same.score(ScoringPolicy::Stable);
689 assert_eq!(stable_score, 3005);
692 assert_eq!(unstable_score, 35);
693 assert!(stable_score > unstable_score);
694 }
695
696 #[test]
697 fn test_score_relative_penalty() {
698 let c = make_candidate(vec![
699 PathTag::SameCanonical,
700 PathTag::InPathEnv(0),
701 PathTag::Relative,
702 ]);
703 let score = c.score(ScoringPolicy::SameBinary);
704 assert_eq!(score, 3032);
706 }
707
708 #[test]
709 fn test_score_non_normalized_penalty() {
710 let c = make_candidate(vec![
711 PathTag::SameCanonical,
712 PathTag::InPathEnv(0),
713 PathTag::NonNormalized,
714 ]);
715 let score = c.score(ScoringPolicy::SameBinary);
716 assert_eq!(score, 3033);
718 }
719
720 #[test]
721 fn test_score_both_penalties_accumulate() {
722 let c = make_candidate(vec![
723 PathTag::SameCanonical,
724 PathTag::InPathEnv(0),
725 PathTag::Relative,
726 PathTag::NonNormalized,
727 ]);
728 let score = c.score(ScoringPolicy::SameBinary);
729 assert_eq!(score, 3030);
731 }
732
733 #[test]
734 fn test_score_build_output_zero_stability() {
735 let c = make_candidate(vec![
736 PathTag::SameCanonical,
737 PathTag::InPathEnv(0),
738 PathTag::BuildOutput,
739 ]);
740 let score = c.score(ScoringPolicy::SameBinary);
741 assert_eq!(score, 3005);
743 }
744
745 #[test]
746 fn test_score_ephemeral_zero_stability() {
747 let c = make_candidate(vec![
748 PathTag::SameCanonical,
749 PathTag::InPathEnv(0),
750 PathTag::Ephemeral,
751 ]);
752 let score = c.score(ScoringPolicy::SameBinary);
753 assert_eq!(score, 3005);
755 }
756
757 #[test]
758 fn test_score_managed_by_stability() {
759 let c = make_candidate(vec![
760 PathTag::SameCanonical,
761 PathTag::InPathEnv(0),
762 PathTag::ManagedBy("mise".to_string()),
763 ]);
764 let score = c.score(ScoringPolicy::SameBinary);
765 assert_eq!(score, 3015);
767 }
768
769 #[test]
770 fn test_score_shim_stability() {
771 let c = make_candidate(vec![
772 PathTag::SameCanonical,
773 PathTag::InPathEnv(0),
774 PathTag::Shim,
775 ]);
776 let score = c.score(ScoringPolicy::SameBinary);
777 assert_eq!(score, 3015);
779 }
780
781 #[test]
782 fn test_score_managed_and_shim_both_present() {
783 let c = make_candidate(vec![
785 PathTag::SameCanonical,
786 PathTag::InPathEnv(0),
787 PathTag::ManagedBy("mise".to_string()),
788 PathTag::Shim,
789 ]);
790 let score = c.score(ScoringPolicy::SameBinary);
791 assert_eq!(score, 3015);
793 }
794
795 #[test]
796 fn test_score_no_in_path_bonus() {
797 let c = make_candidate(vec![PathTag::SameCanonical]);
798 let score = c.score(ScoringPolicy::SameBinary);
799 assert_eq!(score, 3030);
801 }
802
803 #[cfg(unix)]
806 struct TestFixture {
807 _tmpdir: tempfile::TempDir,
808 real_binary: PathBuf,
809 stable_link: PathBuf,
810 stable_dir: PathBuf,
811 other_binary: PathBuf,
812 other_dir: PathBuf,
813 }
814
815 #[cfg(unix)]
816 impl TestFixture {
817 fn new() -> Self {
818 let tmpdir = tempfile::tempdir().unwrap();
819 let base = tmpdir.path();
820
821 let real_dir = base.join("real");
822 let stable_dir = base.join("stable_dir");
823 let other_dir = base.join("other_dir");
824
825 fs::create_dir_all(&real_dir).unwrap();
826 fs::create_dir_all(&stable_dir).unwrap();
827 fs::create_dir_all(&other_dir).unwrap();
828
829 let real_binary = real_dir.join("mybinary");
830 fs::write(&real_binary, "real-content").unwrap();
831 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
832
833 let stable_link = stable_dir.join("mybinary");
834 symlink(&real_binary, &stable_link).unwrap();
835
836 let other_binary = other_dir.join("mybinary");
837 fs::write(&other_binary, "other-content").unwrap();
838 fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
839
840 TestFixture {
841 _tmpdir: tmpdir,
842 real_binary,
843 stable_link,
844 stable_dir,
845 other_binary,
846 other_dir,
847 }
848 }
849
850 fn make_path(&self, dirs: &[&Path]) -> OsString {
851 env::join_paths(dirs).unwrap()
852 }
853 }
854
855 #[cfg(unix)]
856 #[test]
857 fn test_symlink_same_canonical() {
858 let f = TestFixture::new();
859 let path_env = f.make_path(&[&f.stable_dir]);
860
861 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
862
863 assert!(candidates.len() >= 2);
865
866 let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
868 assert!(symlink_cand.tags.contains(&PathTag::SameCanonical));
869 assert!(
870 symlink_cand
871 .tags
872 .iter()
873 .any(|t| matches!(t, PathTag::InPathEnv(_)))
874 );
875 assert!(
876 symlink_cand
877 .tags
878 .iter()
879 .any(|t| matches!(t, PathTag::SymlinkTo(_)))
880 );
881 }
882
883 #[cfg(unix)]
884 #[test]
885 fn test_different_binary_tagged() {
886 let f = TestFixture::new();
887 let path_env = f.make_path(&[&f.other_dir]);
888
889 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
890
891 let other_cand = candidates
892 .iter()
893 .find(|c| c.path == f.other_binary)
894 .unwrap();
895 assert!(other_cand.tags.contains(&PathTag::DifferentBinary));
896 }
897
898 #[cfg(unix)]
899 #[test]
900 fn test_no_path_matches_returns_input_only() {
901 let f = TestFixture::new();
902 let empty_dir = f._tmpdir.path().join("empty");
903 fs::create_dir_all(&empty_dir).unwrap();
904 let path_env = f.make_path(&[&empty_dir]);
905
906 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
907
908 assert!(!candidates.is_empty());
910 assert!(candidates.iter().any(|c| c.tags.contains(&PathTag::Input)));
911 }
912
913 #[cfg(unix)]
914 #[test]
915 fn test_input_tag_present() {
916 let f = TestFixture::new();
917 let path_env = f.make_path(&[&f.stable_dir]);
918
919 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
920
921 let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
922 assert!(input_cand.tags.contains(&PathTag::Input));
923 }
924
925 #[cfg(unix)]
926 #[test]
927 fn test_in_path_env_tag_present() {
928 let f = TestFixture::new();
929 let path_env = f.make_path(&[&f.stable_dir]);
930
931 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
932
933 let path_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
934 assert!(
935 path_cand
936 .tags
937 .iter()
938 .any(|t| matches!(t, PathTag::InPathEnv(_)))
939 );
940 let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
942 assert!(
943 !input_cand
944 .tags
945 .iter()
946 .any(|t| matches!(t, PathTag::InPathEnv(_)))
947 );
948 }
949
950 #[cfg(unix)]
951 #[test]
952 fn test_symlink_to_tag_present() {
953 let f = TestFixture::new();
954 let path_env = f.make_path(&[&f.stable_dir]);
955
956 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
957
958 let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
959 let has_symlink_tag = symlink_cand
960 .tags
961 .iter()
962 .any(|t| matches!(t, PathTag::SymlinkTo(_)));
963 assert!(has_symlink_tag);
964 }
965
966 #[cfg(unix)]
967 #[test]
968 fn test_same_content_detected() {
969 let tmpdir = tempfile::tempdir().unwrap();
971 let base = tmpdir.path();
972
973 let dir_a = base.join("a");
974 let dir_b = base.join("b");
975 fs::create_dir_all(&dir_a).unwrap();
976 fs::create_dir_all(&dir_b).unwrap();
977
978 let binary_a = dir_a.join("mybin");
979 let binary_b = dir_b.join("mybin");
980 fs::write(&binary_a, "identical-content").unwrap();
981 fs::set_permissions(&binary_a, fs::Permissions::from_mode(0o755)).unwrap();
982 fs::write(&binary_b, "identical-content").unwrap();
983 fs::set_permissions(&binary_b, fs::Permissions::from_mode(0o755)).unwrap();
984
985 let path_env = env::join_paths([&dir_b]).unwrap();
986 let candidates = find_candidates_with_path_env(&binary_a, Some(path_env)).unwrap();
987
988 let copy_cand = candidates.iter().find(|c| c.path == binary_b).unwrap();
989 assert!(copy_cand.tags.contains(&PathTag::SameContent));
990 assert!(!copy_cand.tags.contains(&PathTag::SameCanonical));
992 }
993
994 #[cfg(unix)]
995 #[test]
996 fn test_rank_candidates_sorts_by_score_descending() {
997 let f = TestFixture::new();
998 let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
1000
1001 let mut candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
1004 rank_candidates(&mut candidates, ScoringPolicy::SameBinary);
1005
1006 let scores: Vec<i32> = candidates
1008 .iter()
1009 .map(|c| c.score(ScoringPolicy::SameBinary))
1010 .collect();
1011 for w in scores.windows(2) {
1012 assert!(w[0] >= w[1], "scores not descending: {:?}", scores);
1013 }
1014 }
1015
1016 #[test]
1017 fn test_nonexistent_binary_error() {
1018 let result = find_candidates_with_path_env(Path::new("/nonexistent/binary"), None);
1019 assert!(result.is_err());
1020 }
1021
1022 #[cfg(unix)]
1023 #[test]
1024 fn test_duplicate_input_path_skipped() {
1025 let f = TestFixture::new();
1027 let real_dir = f.real_binary.parent().unwrap();
1028 let path_env = f.make_path(&[real_dir]);
1029
1030 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
1031
1032 let count = candidates
1033 .iter()
1034 .filter(|c| c.path == f.real_binary)
1035 .count();
1036 assert_eq!(count, 1, "input path should appear exactly once");
1037 }
1038
1039 #[cfg(unix)]
1040 #[test]
1041 fn test_duplicate_path_directory_deduped() {
1042 let f = TestFixture::new();
1043 let path_env = f.make_path(&[&f.stable_dir, &f.stable_dir]);
1045
1046 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
1047
1048 let stable_count = candidates
1049 .iter()
1050 .filter(|c| c.path == f.stable_link)
1051 .count();
1052 assert_eq!(
1053 stable_count, 1,
1054 "duplicate PATH directory should be deduped"
1055 );
1056 }
1057
1058 #[cfg(unix)]
1059 #[test]
1060 fn test_command_name_lookup() {
1061 let f = TestFixture::new();
1062 let path_env = f.make_path(&[&f.stable_dir]);
1063
1064 let candidates =
1065 find_candidates_with_path_env(Path::new("mybinary"), Some(path_env)).unwrap();
1066
1067 assert!(!candidates.is_empty());
1068 assert!(candidates[0].tags.contains(&PathTag::Input));
1069 }
1070
1071 #[test]
1072 fn test_command_name_not_found() {
1073 let tmpdir = tempfile::tempdir().unwrap();
1074 let empty_dir = tmpdir.path().join("empty");
1075 fs::create_dir_all(&empty_dir).unwrap();
1076 let path_env = env::join_paths([&empty_dir]).unwrap();
1077
1078 let result = find_candidates_with_path_env(Path::new("nonexistent"), Some(path_env));
1079 assert!(result.is_err());
1080 assert!(matches!(result.unwrap_err(), Error::NotInPath(_)));
1081 }
1082
1083 #[test]
1084 fn test_stable_policy_prefers_stable_different_binary_over_unstable_same() {
1085 let stable = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
1087 let unstable = make_candidate(vec![PathTag::SameCanonical, PathTag::BuildOutput]);
1088 assert!(stable.score(ScoringPolicy::Stable) > unstable.score(ScoringPolicy::Stable));
1089 assert!(
1091 unstable.score(ScoringPolicy::SameBinary) > stable.score(ScoringPolicy::SameBinary)
1092 );
1093 }
1094
1095 #[cfg(unix)]
1096 #[test]
1097 fn test_shim_directory_detected() {
1098 let tmpdir = tempfile::tempdir().unwrap();
1099 let shim_dir = tmpdir.path().join(".mise").join("shims");
1100 fs::create_dir_all(&shim_dir).unwrap();
1101 let shim_binary = shim_dir.join("mybin");
1102 fs::write(&shim_binary, "shim-content").unwrap();
1103 fs::set_permissions(&shim_binary, fs::Permissions::from_mode(0o755)).unwrap();
1104
1105 let other_dir = tmpdir.path().join("other");
1106 fs::create_dir_all(&other_dir).unwrap();
1107 let other_binary = other_dir.join("mybin");
1108 fs::write(&other_binary, "other-content").unwrap();
1109 fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
1110
1111 let path_env = env::join_paths([&shim_dir, &other_dir]).unwrap();
1112 let candidates = find_candidates_with_path_env(&other_binary, Some(path_env)).unwrap();
1113
1114 let shim_cand = candidates.iter().find(|c| c.path == shim_binary).unwrap();
1115 assert!(shim_cand.tags.contains(&PathTag::Shim));
1116 }
1117
1118 #[cfg(unix)]
1119 #[test]
1120 fn test_shim_by_symlink_name_mismatch() {
1121 let tmpdir = tempfile::tempdir().unwrap();
1122 let base = tmpdir.path();
1123
1124 let real_dir = base.join("real");
1125 let shim_dir = base.join("bin");
1126 fs::create_dir_all(&real_dir).unwrap();
1127 fs::create_dir_all(&shim_dir).unwrap();
1128
1129 let real_binary = real_dir.join("jj-worktree");
1131 fs::write(&real_binary, "binary-content").unwrap();
1132 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
1133
1134 let shim_link = shim_dir.join("git");
1136 symlink(&real_binary, &shim_link).unwrap();
1137
1138 let input_dir = base.join("input");
1140 fs::create_dir_all(&input_dir).unwrap();
1141 let input_binary = input_dir.join("git");
1142 fs::write(&input_binary, "real-git-content").unwrap();
1143 fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
1144
1145 let path_env = env::join_paths([&shim_dir]).unwrap();
1146 let candidates = find_candidates_with_path_env(&input_binary, Some(path_env)).unwrap();
1147
1148 let shim_cand = candidates.iter().find(|c| c.path == shim_link).unwrap();
1149 assert!(shim_cand.tags.contains(&PathTag::Shim));
1150 }
1151
1152 #[cfg(unix)]
1153 #[test]
1154 fn test_version_suffix_symlink_not_shim() {
1155 let tmpdir = tempfile::tempdir().unwrap();
1156 let base = tmpdir.path();
1157
1158 let real_dir = base.join("real");
1159 let link_dir = base.join("bin");
1160 fs::create_dir_all(&real_dir).unwrap();
1161 fs::create_dir_all(&link_dir).unwrap();
1162
1163 let real_binary = real_dir.join("python3.12");
1164 fs::write(&real_binary, "python-content").unwrap();
1165 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
1166
1167 let link = link_dir.join("python3");
1169 symlink(&real_binary, &link).unwrap();
1170
1171 let input_dir = base.join("input");
1172 fs::create_dir_all(&input_dir).unwrap();
1173 let input_binary = input_dir.join("python3");
1174 fs::write(&input_binary, "python-content").unwrap();
1175 fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
1176
1177 let path_env = env::join_paths([&link_dir]).unwrap();
1178 let candidates = find_candidates_with_path_env(&input_binary, Some(path_env)).unwrap();
1179
1180 let link_cand = candidates.iter().find(|c| c.path == link).unwrap();
1181 assert!(!link_cand.tags.contains(&PathTag::Shim));
1182 }
1183
1184 #[cfg(unix)]
1194 #[test]
1195 fn test_durability_accessor_per_candidate() {
1196 let durable = Candidate::for_test(
1198 PathBuf::from("/opt/homebrew/bin/git"),
1199 PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
1200 vec![PathTag::Input],
1201 );
1202 assert_eq!(durable.durability(), Durability::Durable);
1203 assert!(durable.is_stable());
1204
1205 let versioned = Candidate::for_test(
1206 PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
1207 PathBuf::from("/opt/homebrew/Cellar/git/2.44.0/bin/git"),
1208 vec![PathTag::SameCanonical],
1209 );
1210 assert_eq!(versioned.durability(), Durability::NotDurable);
1211 assert!(!versioned.is_stable());
1212 }
1213
1214 #[test]
1215 fn test_is_stable_false_for_unknown() {
1216 let c = Candidate::for_test(
1217 PathBuf::from("/home/u/.local/bin/jupyter"),
1218 PathBuf::from("/home/u/.local/bin/jupyter"),
1219 vec![PathTag::Input],
1220 );
1221 assert_eq!(c.durability(), Durability::Unknown);
1222 assert!(!c.is_stable());
1223 }
1224
1225 #[test]
1226 fn test_accessors_return_internal_fields() {
1227 let c = Candidate::for_test(
1228 PathBuf::from("/a/b"),
1229 PathBuf::from("/c/d"),
1230 vec![PathTag::Input, PathTag::SameCanonical],
1231 );
1232 assert_eq!(c.path(), Path::new("/a/b"));
1233 assert_eq!(c.canonical(), Path::new("/c/d"));
1234 assert_eq!(c.tags(), &[PathTag::Input, PathTag::SameCanonical]);
1235 }
1236
1237 #[cfg(unix)]
1238 #[test]
1239 fn test_find_candidates_with_path_env_is_unsorted_discovery() {
1240 let f = TestFixture::new();
1243 let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
1244 let candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
1245 assert!(candidates[0].tags().contains(&PathTag::Input));
1246 }
1247
1248 #[cfg(unix)]
1249 #[test]
1250 fn test_find_candidates_non_empty_on_success() {
1251 let f = TestFixture::new();
1252 let candidates = find_candidates_with_path_env(&f.real_binary, None).unwrap();
1254 assert!(!candidates.is_empty());
1255 assert!(
1256 candidates
1257 .iter()
1258 .any(|c| c.tags().contains(&PathTag::Input))
1259 );
1260 }
1261
1262 #[cfg(unix)]
1263 #[test]
1264 fn test_resolve_stable_path_returns_tagged_candidate() {
1265 let f = TestFixture::new();
1266 let best = resolve_stable_path(&f.real_binary, ScoringPolicy::SameBinary).unwrap();
1269 assert!(!best.tags().is_empty());
1270 }
1271}