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::path_analysis;
9
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum Error {
13 NotFound(PathBuf),
15 NotAFile(PathBuf),
17 NoFileName(PathBuf),
19 NotInPath(String),
21 Canonicalize(PathBuf, std::io::Error),
23 Metadata(PathBuf, std::io::Error),
25}
26
27impl fmt::Display for Error {
28 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29 match self {
30 Error::NotFound(p) => write!(f, "'{}' does not exist", p.display()),
31 Error::NotAFile(p) => write!(f, "'{}' is not a file", p.display()),
32 Error::NoFileName(p) => write!(f, "'{}' has no file name", p.display()),
33 Error::NotInPath(name) => write!(f, "'{}' not found in PATH", name),
34 Error::Canonicalize(p, e) => {
35 write!(f, "cannot canonicalize '{}': {}", p.display(), e)
36 }
37 Error::Metadata(p, e) => write!(f, "cannot stat '{}': {}", p.display(), e),
38 }
39 }
40}
41
42impl std::error::Error for Error {
43 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
44 match self {
45 Error::Canonicalize(_, e) | Error::Metadata(_, e) => Some(e),
46 _ => None,
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum PathTag {
54 Input,
56 InPathEnv(usize),
58 SymlinkTo(PathBuf),
60 Shim,
62 SameCanonical,
64 SameContent,
66 Relative,
68 NonNormalized,
70 DifferentBinary,
72 ManagedBy(String),
74 BuildOutput,
76 Ephemeral,
78}
79
80#[derive(Debug, Clone)]
81pub struct Candidate {
82 pub path: PathBuf,
83 pub canonical: PathBuf,
84 pub tags: Vec<PathTag>,
85}
86
87#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
88#[non_exhaustive]
89pub enum ScoringPolicy {
90 #[default]
91 SameBinary,
92 Stable,
93}
94
95impl Candidate {
96 pub fn score(&self, policy: ScoringPolicy) -> i32 {
97 let binary_score = if self.tags.contains(&PathTag::SameCanonical) {
98 3
99 } else if self.tags.contains(&PathTag::SameContent) {
100 2
101 } else {
102 0
103 };
104
105 let has_build_output = self.tags.contains(&PathTag::BuildOutput);
106 let has_ephemeral = self.tags.contains(&PathTag::Ephemeral);
107 let has_managed = self.tags.iter().any(|t| matches!(t, PathTag::ManagedBy(_)));
108 let has_shim = self.tags.contains(&PathTag::Shim);
109
110 let stability_score = if has_build_output || has_ephemeral {
111 0
112 } else if has_managed || has_shim {
113 1
114 } else {
115 3
116 };
117
118 let in_path_bonus = if self.tags.iter().any(|t| matches!(t, PathTag::InPathEnv(_))) {
119 5
120 } else {
121 0
122 };
123
124 let mut penalty = 0i32;
125 if self.tags.contains(&PathTag::Relative) {
126 penalty -= 3;
127 }
128 if self.tags.contains(&PathTag::NonNormalized) {
129 penalty -= 2;
130 }
131
132 match policy {
133 ScoringPolicy::SameBinary => {
134 binary_score * 1000 + stability_score * 10 + in_path_bonus + penalty
135 }
136 ScoringPolicy::Stable => {
137 stability_score * 1000 + binary_score * 10 + in_path_bonus + penalty
138 }
139 }
140 }
141
142 pub fn path_order(&self) -> usize {
144 self.tags
145 .iter()
146 .find_map(|t| match t {
147 PathTag::InPathEnv(order) => Some(*order),
148 _ => None,
149 })
150 .unwrap_or(usize::MAX)
151 }
152}
153
154fn tag_path(path: &Path, input_canonical: &Path, input_path: &Path) -> (Vec<PathTag>, PathBuf) {
155 let mut tags = Vec::new();
156
157 if !path.is_absolute() {
159 tags.push(PathTag::Relative);
160 }
161
162 let path_str = path.to_string_lossy();
164 if path_str.contains("/./")
165 || path_str.contains("/../")
166 || path.components().any(|c| {
167 matches!(
168 c,
169 std::path::Component::CurDir | std::path::Component::ParentDir
170 )
171 })
172 {
173 tags.push(PathTag::NonNormalized);
174 }
175
176 let symlink_target = fs::read_link(path).ok();
178 if let Some(ref target) = symlink_target {
179 tags.push(PathTag::SymlinkTo(target.clone()));
180 }
181
182 if path_analysis::is_shim_path(path) {
184 tags.push(PathTag::Shim);
185 } else if let Some(ref target) = symlink_target {
186 let candidate_name = path
187 .file_name()
188 .map(|n| n.to_string_lossy().into_owned())
189 .unwrap_or_default();
190 let target_name = target
191 .file_name()
192 .map(|n| n.to_string_lossy().into_owned())
193 .unwrap_or_default();
194 if !candidate_name.is_empty()
195 && !target_name.is_empty()
196 && path_analysis::is_shim_by_name(&candidate_name, &target_name)
197 {
198 tags.push(PathTag::Shim);
199 }
200 }
201
202 let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
204
205 if canonical == input_canonical {
207 tags.push(PathTag::SameCanonical);
208 } else if path_analysis::files_have_same_content(path, input_path) {
209 tags.push(PathTag::SameContent);
210 } else {
211 tags.push(PathTag::DifferentBinary);
212 }
213
214 if let Some(info) = path_analysis::detect_version_manager(path) {
216 tags.push(PathTag::ManagedBy(info.name));
217 }
218
219 if path_analysis::is_build_output(path) {
221 tags.push(PathTag::BuildOutput);
222 }
223
224 if path_analysis::is_ephemeral(path) {
226 tags.push(PathTag::Ephemeral);
227 }
228
229 (tags, canonical)
230}
231
232pub fn find_candidates_with_env(
233 binary: &Path,
234 path_env: Option<OsString>,
235 policy: ScoringPolicy,
236) -> Result<Vec<Candidate>, Error> {
237 let resolved_binary;
239 let binary = if !binary.to_string_lossy().contains('/') {
240 let name = binary
241 .file_name()
242 .ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
243 resolved_binary = path_env
244 .as_ref()
245 .and_then(|pv| {
246 env::split_paths(pv)
247 .map(|dir| dir.join(name))
248 .find(|c| path_analysis::is_executable(c))
249 })
250 .ok_or_else(|| Error::NotInPath(binary.display().to_string()))?;
251 resolved_binary.as_path()
252 } else {
253 binary
254 };
255
256 if !binary.exists() {
258 return Err(Error::NotFound(binary.to_path_buf()));
259 }
260 let metadata = fs::metadata(binary).map_err(|e| Error::Metadata(binary.to_path_buf(), e))?;
261 if !metadata.is_file() {
262 return Err(Error::NotAFile(binary.to_path_buf()));
263 }
264
265 let input_canonical =
267 fs::canonicalize(binary).map_err(|e| Error::Canonicalize(binary.to_path_buf(), e))?;
268
269 let file_name = binary
270 .file_name()
271 .ok_or_else(|| Error::NoFileName(binary.to_path_buf()))?;
272
273 let mut candidates = Vec::new();
274 let mut path_order: usize = 0;
275 let mut seen_paths = HashSet::new();
276
277 {
279 let (mut tags, canonical) = tag_path(binary, &input_canonical, binary);
280 tags.insert(0, PathTag::Input);
281 candidates.push(Candidate {
282 path: binary.to_path_buf(),
283 canonical,
284 tags,
285 });
286 }
287
288 if let Some(ref path_var) = path_env {
290 for dir in env::split_paths(path_var) {
291 let candidate_path = dir.join(file_name);
292 if candidate_path == binary {
293 continue;
294 }
295 if !path_analysis::is_executable(&candidate_path) {
296 continue;
297 }
298 if !seen_paths.insert(candidate_path.clone()) {
300 continue;
301 }
302 let (mut tags, canonical) = tag_path(&candidate_path, &input_canonical, binary);
303 tags.insert(0, PathTag::InPathEnv(path_order));
304 candidates.push(Candidate {
305 path: candidate_path,
306 canonical,
307 tags,
308 });
309 path_order += 1;
310 }
311 }
312
313 candidates.sort_by(|a, b| {
315 let score_cmp = b.score(policy).cmp(&a.score(policy));
316 score_cmp.then(a.path_order().cmp(&b.path_order()))
317 });
318
319 Ok(candidates)
320}
321
322pub fn find_candidates(binary: &Path, policy: ScoringPolicy) -> Result<Vec<Candidate>, Error> {
323 find_candidates_with_env(binary, env::var_os("PATH"), policy)
324}
325
326pub fn resolve_stable_path(binary: &Path, policy: ScoringPolicy) -> Result<Candidate, Error> {
327 let candidates = find_candidates(binary, policy)?;
328 if let Some(first) = candidates.into_iter().next() {
329 Ok(first)
330 } else {
331 let canonical = fs::canonicalize(binary).unwrap_or_else(|_| binary.to_path_buf());
332 Ok(Candidate {
333 path: canonical.clone(),
334 canonical,
335 tags: vec![],
336 })
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use std::os::unix::fs::{PermissionsExt, symlink};
344
345 fn make_candidate(tags: Vec<PathTag>) -> Candidate {
348 Candidate {
349 path: PathBuf::from("/dummy"),
350 canonical: PathBuf::from("/dummy"),
351 tags,
352 }
353 }
354
355 #[test]
356 fn test_score_same_binary_policy_highest() {
357 let c = make_candidate(vec![PathTag::SameCanonical, PathTag::InPathEnv(0)]);
359 let score = c.score(ScoringPolicy::SameBinary);
360 assert_eq!(score, 3035);
362 }
363
364 #[test]
365 fn test_score_same_content_in_path() {
366 let c = make_candidate(vec![PathTag::SameContent, PathTag::InPathEnv(0)]);
367 let score = c.score(ScoringPolicy::SameBinary);
368 assert_eq!(score, 2035);
370 }
371
372 #[test]
373 fn test_score_different_binary() {
374 let c = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
375 let score = c.score(ScoringPolicy::SameBinary);
376 assert_eq!(score, 35);
378 }
379
380 #[test]
381 fn test_score_stable_policy_stable_path_wins() {
382 let stable_different =
384 make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
385 let unstable_same = make_candidate(vec![
386 PathTag::SameCanonical,
387 PathTag::InPathEnv(0),
388 PathTag::BuildOutput,
389 ]);
390 let stable_score = stable_different.score(ScoringPolicy::Stable);
391 let unstable_score = unstable_same.score(ScoringPolicy::Stable);
392 assert_eq!(stable_score, 3005);
395 assert_eq!(unstable_score, 35);
396 assert!(stable_score > unstable_score);
397 }
398
399 #[test]
400 fn test_score_relative_penalty() {
401 let c = make_candidate(vec![
402 PathTag::SameCanonical,
403 PathTag::InPathEnv(0),
404 PathTag::Relative,
405 ]);
406 let score = c.score(ScoringPolicy::SameBinary);
407 assert_eq!(score, 3032);
409 }
410
411 #[test]
412 fn test_score_non_normalized_penalty() {
413 let c = make_candidate(vec![
414 PathTag::SameCanonical,
415 PathTag::InPathEnv(0),
416 PathTag::NonNormalized,
417 ]);
418 let score = c.score(ScoringPolicy::SameBinary);
419 assert_eq!(score, 3033);
421 }
422
423 #[test]
424 fn test_score_both_penalties_accumulate() {
425 let c = make_candidate(vec![
426 PathTag::SameCanonical,
427 PathTag::InPathEnv(0),
428 PathTag::Relative,
429 PathTag::NonNormalized,
430 ]);
431 let score = c.score(ScoringPolicy::SameBinary);
432 assert_eq!(score, 3030);
434 }
435
436 #[test]
437 fn test_score_build_output_zero_stability() {
438 let c = make_candidate(vec![
439 PathTag::SameCanonical,
440 PathTag::InPathEnv(0),
441 PathTag::BuildOutput,
442 ]);
443 let score = c.score(ScoringPolicy::SameBinary);
444 assert_eq!(score, 3005);
446 }
447
448 #[test]
449 fn test_score_ephemeral_zero_stability() {
450 let c = make_candidate(vec![
451 PathTag::SameCanonical,
452 PathTag::InPathEnv(0),
453 PathTag::Ephemeral,
454 ]);
455 let score = c.score(ScoringPolicy::SameBinary);
456 assert_eq!(score, 3005);
458 }
459
460 #[test]
461 fn test_score_managed_by_stability() {
462 let c = make_candidate(vec![
463 PathTag::SameCanonical,
464 PathTag::InPathEnv(0),
465 PathTag::ManagedBy("mise".to_string()),
466 ]);
467 let score = c.score(ScoringPolicy::SameBinary);
468 assert_eq!(score, 3015);
470 }
471
472 #[test]
473 fn test_score_shim_stability() {
474 let c = make_candidate(vec![
475 PathTag::SameCanonical,
476 PathTag::InPathEnv(0),
477 PathTag::Shim,
478 ]);
479 let score = c.score(ScoringPolicy::SameBinary);
480 assert_eq!(score, 3015);
482 }
483
484 #[test]
485 fn test_score_managed_and_shim_both_present() {
486 let c = make_candidate(vec![
488 PathTag::SameCanonical,
489 PathTag::InPathEnv(0),
490 PathTag::ManagedBy("mise".to_string()),
491 PathTag::Shim,
492 ]);
493 let score = c.score(ScoringPolicy::SameBinary);
494 assert_eq!(score, 3015);
496 }
497
498 #[test]
499 fn test_score_no_in_path_bonus() {
500 let c = make_candidate(vec![PathTag::SameCanonical]);
501 let score = c.score(ScoringPolicy::SameBinary);
502 assert_eq!(score, 3030);
504 }
505
506 struct TestFixture {
509 _tmpdir: tempfile::TempDir,
510 real_binary: PathBuf,
511 stable_link: PathBuf,
512 stable_dir: PathBuf,
513 other_binary: PathBuf,
514 other_dir: PathBuf,
515 }
516
517 impl TestFixture {
518 fn new() -> Self {
519 let tmpdir = tempfile::tempdir().unwrap();
520 let base = tmpdir.path();
521
522 let real_dir = base.join("real");
523 let stable_dir = base.join("stable_dir");
524 let other_dir = base.join("other_dir");
525
526 fs::create_dir_all(&real_dir).unwrap();
527 fs::create_dir_all(&stable_dir).unwrap();
528 fs::create_dir_all(&other_dir).unwrap();
529
530 let real_binary = real_dir.join("mybinary");
531 fs::write(&real_binary, "real-content").unwrap();
532 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
533
534 let stable_link = stable_dir.join("mybinary");
535 symlink(&real_binary, &stable_link).unwrap();
536
537 let other_binary = other_dir.join("mybinary");
538 fs::write(&other_binary, "other-content").unwrap();
539 fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
540
541 TestFixture {
542 _tmpdir: tmpdir,
543 real_binary,
544 stable_link,
545 stable_dir,
546 other_binary,
547 other_dir,
548 }
549 }
550
551 fn make_path(&self, dirs: &[&Path]) -> OsString {
552 env::join_paths(dirs).unwrap()
553 }
554 }
555
556 #[test]
557 fn test_symlink_same_canonical() {
558 let f = TestFixture::new();
559 let path_env = f.make_path(&[&f.stable_dir]);
560
561 let candidates =
562 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
563 .unwrap();
564
565 assert_eq!(candidates.len(), 2);
567
568 let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
570 assert!(symlink_cand.tags.contains(&PathTag::SameCanonical));
571 assert!(
572 symlink_cand
573 .tags
574 .iter()
575 .any(|t| matches!(t, PathTag::InPathEnv(_)))
576 );
577 assert!(
578 symlink_cand
579 .tags
580 .iter()
581 .any(|t| matches!(t, PathTag::SymlinkTo(_)))
582 );
583 }
584
585 #[test]
586 fn test_different_binary_tagged() {
587 let f = TestFixture::new();
588 let path_env = f.make_path(&[&f.other_dir]);
589
590 let candidates =
591 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
592 .unwrap();
593
594 let other_cand = candidates
595 .iter()
596 .find(|c| c.path == f.other_binary)
597 .unwrap();
598 assert!(other_cand.tags.contains(&PathTag::DifferentBinary));
599 }
600
601 #[test]
602 fn test_no_path_matches_returns_input_only() {
603 let f = TestFixture::new();
604 let empty_dir = f._tmpdir.path().join("empty");
605 fs::create_dir_all(&empty_dir).unwrap();
606 let path_env = f.make_path(&[&empty_dir]);
607
608 let candidates =
609 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
610 .unwrap();
611
612 assert_eq!(candidates.len(), 1);
613 assert!(candidates[0].tags.contains(&PathTag::Input));
614 }
615
616 #[test]
617 fn test_input_tag_present() {
618 let f = TestFixture::new();
619 let path_env = f.make_path(&[&f.stable_dir]);
620
621 let candidates =
622 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
623 .unwrap();
624
625 let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
626 assert!(input_cand.tags.contains(&PathTag::Input));
627 }
628
629 #[test]
630 fn test_in_path_env_tag_present() {
631 let f = TestFixture::new();
632 let path_env = f.make_path(&[&f.stable_dir]);
633
634 let candidates =
635 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
636 .unwrap();
637
638 let path_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
639 assert!(
640 path_cand
641 .tags
642 .iter()
643 .any(|t| matches!(t, PathTag::InPathEnv(_)))
644 );
645 let input_cand = candidates.iter().find(|c| c.path == f.real_binary).unwrap();
647 assert!(
648 !input_cand
649 .tags
650 .iter()
651 .any(|t| matches!(t, PathTag::InPathEnv(_)))
652 );
653 }
654
655 #[test]
656 fn test_symlink_to_tag_present() {
657 let f = TestFixture::new();
658 let path_env = f.make_path(&[&f.stable_dir]);
659
660 let candidates =
661 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
662 .unwrap();
663
664 let symlink_cand = candidates.iter().find(|c| c.path == f.stable_link).unwrap();
665 let has_symlink_tag = symlink_cand
666 .tags
667 .iter()
668 .any(|t| matches!(t, PathTag::SymlinkTo(_)));
669 assert!(has_symlink_tag);
670 }
671
672 #[test]
673 fn test_same_content_detected() {
674 let tmpdir = tempfile::tempdir().unwrap();
676 let base = tmpdir.path();
677
678 let dir_a = base.join("a");
679 let dir_b = base.join("b");
680 fs::create_dir_all(&dir_a).unwrap();
681 fs::create_dir_all(&dir_b).unwrap();
682
683 let binary_a = dir_a.join("mybin");
684 let binary_b = dir_b.join("mybin");
685 fs::write(&binary_a, "identical-content").unwrap();
686 fs::set_permissions(&binary_a, fs::Permissions::from_mode(0o755)).unwrap();
687 fs::write(&binary_b, "identical-content").unwrap();
688 fs::set_permissions(&binary_b, fs::Permissions::from_mode(0o755)).unwrap();
689
690 let path_env = env::join_paths([&dir_b]).unwrap();
691 let candidates =
692 find_candidates_with_env(&binary_a, Some(path_env), ScoringPolicy::SameBinary).unwrap();
693
694 let copy_cand = candidates.iter().find(|c| c.path == binary_b).unwrap();
695 assert!(copy_cand.tags.contains(&PathTag::SameContent));
696 assert!(!copy_cand.tags.contains(&PathTag::SameCanonical));
698 }
699
700 #[test]
701 fn test_sorted_by_score_descending() {
702 let f = TestFixture::new();
703 let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
705
706 let candidates =
707 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
708 .unwrap();
709
710 let scores: Vec<i32> = candidates
712 .iter()
713 .map(|c| c.score(ScoringPolicy::SameBinary))
714 .collect();
715 for w in scores.windows(2) {
716 assert!(w[0] >= w[1], "scores not descending: {:?}", scores);
717 }
718 }
719
720 #[test]
721 fn test_nonexistent_binary_error() {
722 let result = find_candidates_with_env(
723 Path::new("/nonexistent/binary"),
724 None,
725 ScoringPolicy::SameBinary,
726 );
727 assert!(result.is_err());
728 }
729
730 #[test]
731 fn test_duplicate_input_path_skipped() {
732 let f = TestFixture::new();
734 let real_dir = f.real_binary.parent().unwrap();
735 let path_env = f.make_path(&[real_dir]);
736
737 let candidates =
738 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
739 .unwrap();
740
741 let count = candidates
742 .iter()
743 .filter(|c| c.path == f.real_binary)
744 .count();
745 assert_eq!(count, 1, "input path should appear exactly once");
746 }
747
748 #[test]
749 fn test_duplicate_path_directory_deduped() {
750 let f = TestFixture::new();
751 let path_env = f.make_path(&[&f.stable_dir, &f.stable_dir]);
753
754 let candidates =
755 find_candidates_with_env(&f.real_binary, Some(path_env), ScoringPolicy::SameBinary)
756 .unwrap();
757
758 let stable_count = candidates
759 .iter()
760 .filter(|c| c.path == f.stable_link)
761 .count();
762 assert_eq!(
763 stable_count, 1,
764 "duplicate PATH directory should be deduped"
765 );
766 }
767
768 #[test]
769 fn test_command_name_lookup() {
770 let f = TestFixture::new();
771 let path_env = f.make_path(&[&f.stable_dir]);
772
773 let candidates = find_candidates_with_env(
774 Path::new("mybinary"),
775 Some(path_env),
776 ScoringPolicy::SameBinary,
777 )
778 .unwrap();
779
780 assert!(!candidates.is_empty());
781 assert!(candidates[0].tags.contains(&PathTag::Input));
782 }
783
784 #[test]
785 fn test_command_name_not_found() {
786 let tmpdir = tempfile::tempdir().unwrap();
787 let empty_dir = tmpdir.path().join("empty");
788 fs::create_dir_all(&empty_dir).unwrap();
789 let path_env = env::join_paths([&empty_dir]).unwrap();
790
791 let result = find_candidates_with_env(
792 Path::new("nonexistent"),
793 Some(path_env),
794 ScoringPolicy::SameBinary,
795 );
796 assert!(result.is_err());
797 assert!(matches!(result.unwrap_err(), Error::NotInPath(_)));
798 }
799
800 #[test]
801 fn test_stable_policy_prefers_stable_different_binary_over_unstable_same() {
802 let stable = make_candidate(vec![PathTag::DifferentBinary, PathTag::InPathEnv(0)]);
804 let unstable = make_candidate(vec![PathTag::SameCanonical, PathTag::BuildOutput]);
805 assert!(stable.score(ScoringPolicy::Stable) > unstable.score(ScoringPolicy::Stable));
806 assert!(
808 unstable.score(ScoringPolicy::SameBinary) > stable.score(ScoringPolicy::SameBinary)
809 );
810 }
811
812 #[test]
813 fn test_shim_directory_detected() {
814 let tmpdir = tempfile::tempdir().unwrap();
815 let shim_dir = tmpdir.path().join(".mise").join("shims");
816 fs::create_dir_all(&shim_dir).unwrap();
817 let shim_binary = shim_dir.join("mybin");
818 fs::write(&shim_binary, "shim-content").unwrap();
819 fs::set_permissions(&shim_binary, fs::Permissions::from_mode(0o755)).unwrap();
820
821 let other_dir = tmpdir.path().join("other");
822 fs::create_dir_all(&other_dir).unwrap();
823 let other_binary = other_dir.join("mybin");
824 fs::write(&other_binary, "other-content").unwrap();
825 fs::set_permissions(&other_binary, fs::Permissions::from_mode(0o755)).unwrap();
826
827 let path_env = env::join_paths([&shim_dir, &other_dir]).unwrap();
828 let candidates =
829 find_candidates_with_env(&other_binary, Some(path_env), ScoringPolicy::SameBinary)
830 .unwrap();
831
832 let shim_cand = candidates.iter().find(|c| c.path == shim_binary).unwrap();
833 assert!(shim_cand.tags.contains(&PathTag::Shim));
834 }
835
836 #[test]
837 fn test_shim_by_symlink_name_mismatch() {
838 let tmpdir = tempfile::tempdir().unwrap();
839 let base = tmpdir.path();
840
841 let real_dir = base.join("real");
842 let shim_dir = base.join("bin");
843 fs::create_dir_all(&real_dir).unwrap();
844 fs::create_dir_all(&shim_dir).unwrap();
845
846 let real_binary = real_dir.join("jj-worktree");
848 fs::write(&real_binary, "binary-content").unwrap();
849 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
850
851 let shim_link = shim_dir.join("git");
853 symlink(&real_binary, &shim_link).unwrap();
854
855 let input_dir = base.join("input");
857 fs::create_dir_all(&input_dir).unwrap();
858 let input_binary = input_dir.join("git");
859 fs::write(&input_binary, "real-git-content").unwrap();
860 fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
861
862 let path_env = env::join_paths([&shim_dir]).unwrap();
863 let candidates =
864 find_candidates_with_env(&input_binary, Some(path_env), ScoringPolicy::SameBinary)
865 .unwrap();
866
867 let shim_cand = candidates.iter().find(|c| c.path == shim_link).unwrap();
868 assert!(shim_cand.tags.contains(&PathTag::Shim));
869 }
870
871 #[test]
872 fn test_version_suffix_symlink_not_shim() {
873 let tmpdir = tempfile::tempdir().unwrap();
874 let base = tmpdir.path();
875
876 let real_dir = base.join("real");
877 let link_dir = base.join("bin");
878 fs::create_dir_all(&real_dir).unwrap();
879 fs::create_dir_all(&link_dir).unwrap();
880
881 let real_binary = real_dir.join("python3.12");
882 fs::write(&real_binary, "python-content").unwrap();
883 fs::set_permissions(&real_binary, fs::Permissions::from_mode(0o755)).unwrap();
884
885 let link = link_dir.join("python3");
887 symlink(&real_binary, &link).unwrap();
888
889 let input_dir = base.join("input");
890 fs::create_dir_all(&input_dir).unwrap();
891 let input_binary = input_dir.join("python3");
892 fs::write(&input_binary, "python-content").unwrap();
893 fs::set_permissions(&input_binary, fs::Permissions::from_mode(0o755)).unwrap();
894
895 let path_env = env::join_paths([&link_dir]).unwrap();
896 let candidates =
897 find_candidates_with_env(&input_binary, Some(path_env), ScoringPolicy::SameBinary)
898 .unwrap();
899
900 let link_cand = candidates.iter().find(|c| c.path == link).unwrap();
901 assert!(!link_cand.tags.contains(&PathTag::Shim));
902 }
903}