Skip to main content

stable_which/
candidate.rs

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    /// Binary not found at the specified path
14    NotFound(PathBuf),
15    /// Path exists but is not a file
16    NotAFile(PathBuf),
17    /// Cannot determine the file name from the path
18    NoFileName(PathBuf),
19    /// Command name not found in PATH
20    NotInPath(String),
21    /// Failed to canonicalize path
22    Canonicalize(PathBuf, std::io::Error),
23    /// Failed to read file metadata
24    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    /// The path that was passed as input
55    Input,
56    /// Found in PATH environment variable (holds discovery order: 0 = first PATH match)
57    InPathEnv(usize),
58    /// Is a symlink, pointing to the given target
59    SymlinkTo(PathBuf),
60    /// In a shim directory or symlink target name doesn't match candidate name
61    Shim,
62    /// Canonical path matches the input binary (same file)
63    SameCanonical,
64    /// File content matches the input binary byte-for-byte (copied identical binary)
65    SameContent,
66    /// Relative path (doesn't start with /)
67    Relative,
68    /// Non-normalized path (contains .. or . components)
69    NonNormalized,
70    /// Same name but different binary (canonical and content both differ)
71    DifferentBinary,
72    /// Under a version manager (holds manager name)
73    ManagedBy(String),
74    /// Inside a build output directory
75    BuildOutput,
76    /// Inside a temporary/cache directory
77    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    /// PATH discovery order from InPathEnv tag, or usize::MAX if not from PATH
143    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    // Relative
158    if !path.is_absolute() {
159        tags.push(PathTag::Relative);
160    }
161
162    // NonNormalized
163    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    // SymlinkTo
177    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    // Shim
183    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    // Canonical
203    let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
204
205    // SameCanonical / SameContent / DifferentBinary
206    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    // ManagedBy
215    if let Some(info) = path_analysis::detect_version_manager(path) {
216        tags.push(PathTag::ManagedBy(info.name));
217    }
218
219    // BuildOutput
220    if path_analysis::is_build_output(path) {
221        tags.push(PathTag::BuildOutput);
222    }
223
224    // Ephemeral
225    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    // Command name resolution: if no '/' in path, look up in PATH
238    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    // Verify input exists and is a file
257    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    // Compute input canonical path
266    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    // Add input path as a candidate with Input tag (no InPathEnv, no path_order)
278    {
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    // Search PATH for same-name binaries
289    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            // Skip duplicate PATH entries (same directory appearing multiple times in PATH)
299            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    // Sort by score descending, then by PATH discovery order ascending (deterministic tie-breaking)
314    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    // --- score() tests ---
346
347    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        // SameCanonical + InPathEnv should be the highest score in SameBinary policy
358        let c = make_candidate(vec![PathTag::SameCanonical, PathTag::InPathEnv(0)]);
359        let score = c.score(ScoringPolicy::SameBinary);
360        // binary=3, stability=3, in_path=5 => 3*1000 + 3*10 + 5 = 3035
361        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        // binary=2, stability=3, in_path=5 => 2*1000 + 3*10 + 5 = 2035
369        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        // binary=0, stability=3, in_path=5 => 0 + 30 + 5 = 35
377        assert_eq!(score, 35);
378    }
379
380    #[test]
381    fn test_score_stable_policy_stable_path_wins() {
382        // In Stable policy, a DifferentBinary on stable path beats SameCanonical on unstable
383        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        // stable_different: stability=3, binary=0 => 3*1000 + 0*10 + 5 = 3005
393        // unstable_same: stability=0, binary=3 => 0*1000 + 3*10 + 5 = 35
394        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        // 3*1000 + 3*10 + 5 - 3 = 3032
408        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        // 3*1000 + 3*10 + 5 - 2 = 3033
420        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        // 3*1000 + 3*10 + 5 - 3 - 2 = 3030
433        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        // binary=3, stability=0, in_path=5 => 3*1000 + 0 + 5 = 3005
445        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        // binary=3, stability=0, in_path=5 => 3*1000 + 0 + 5 = 3005
457        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
469        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
481        assert_eq!(score, 3015);
482    }
483
484    #[test]
485    fn test_score_managed_and_shim_both_present() {
486        // Both ManagedBy and Shim: stability = 1 (same as having just one)
487        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
495        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        // binary=3, stability=3, in_path=0 => 3*1000 + 3*10 + 0 = 3030
503        assert_eq!(score, 3030);
504    }
505
506    // --- find_candidates_with_env tests ---
507
508    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        // Should have 2 candidates: input + symlink
566        assert_eq!(candidates.len(), 2);
567
568        // The symlink candidate should have SameCanonical tag
569        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        // Input candidate should NOT have InPathEnv
646        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        // Create a copy (not symlink) with identical content
675        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        // Should NOT have SameCanonical since they are different files
697        assert!(!copy_cand.tags.contains(&PathTag::SameCanonical));
698    }
699
700    #[test]
701    fn test_sorted_by_score_descending() {
702        let f = TestFixture::new();
703        // stable_dir has symlink (SameCanonical), other_dir has different binary
704        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        // Verify scores are in descending order
711        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        // When input path is in PATH, it should not appear twice
733        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        // Same directory appears twice in PATH
752        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        // Stable policy: stable DifferentBinary > unstable SameCanonical
803        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        // But SameBinary policy reverses this
807        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        // Real binary named "jj-worktree"
847        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        // Symlink "git" -> "jj-worktree" (name mismatch = shim)
852        let shim_link = shim_dir.join("git");
853        symlink(&real_binary, &shim_link).unwrap();
854
855        // Also create a real "git" binary for input
856        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        // "python3" -> "python3.12" (prefix match = NOT shim)
886        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}