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::durability::{self, Durability};
9use crate::path_analysis;
10
11/// Errors that can be returned by [`find_candidates`] and related functions.
12#[derive(Debug)]
13#[non_exhaustive]
14pub enum Error {
15    /// Binary not found at the specified path
16    NotFound(PathBuf),
17    /// Path exists but is not a file
18    NotAFile(PathBuf),
19    /// Cannot determine the file name from the path
20    NoFileName(PathBuf),
21    /// Command name not found in PATH
22    NotInPath(String),
23    /// Failed to canonicalize path
24    Canonicalize(PathBuf, std::io::Error),
25    /// Failed to read file metadata
26    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/// Observed properties of a candidate path.
54///
55/// Tags describe how a candidate was found and what role it plays. Multiple
56/// tags can be attached to a single candidate. See [`Candidate::tags`].
57#[derive(Debug, Clone, PartialEq, Eq)]
58#[non_exhaustive]
59pub enum PathTag {
60    /// The path that was passed as input
61    Input,
62    /// Found in PATH environment variable (holds discovery order: 0 = first PATH match)
63    InPathEnv(usize),
64    /// Is a symlink, pointing to the given target
65    SymlinkTo(PathBuf),
66    /// In a shim directory or symlink target name doesn't match candidate name
67    Shim,
68    /// Canonical path matches the input binary (same file)
69    SameCanonical,
70    /// File content matches the input binary byte-for-byte (copied identical binary)
71    SameContent,
72    /// Relative path (doesn't start with /)
73    Relative,
74    /// Non-normalized path (contains .. or . components)
75    NonNormalized,
76    /// Same name but different binary (canonical and content both differ)
77    DifferentBinary,
78    /// Under a version manager (holds manager name)
79    ManagedBy(String),
80    /// Inside a build output directory
81    BuildOutput,
82    /// Inside a temporary/cache directory
83    Ephemeral,
84    /// Not directly executable (no execute permission, or ambiguous bare relative path)
85    NotExecutable,
86}
87
88/// A discovered location for a binary, with the observed [`PathTag`]s that
89/// describe it.
90///
91/// Fields are private; use the accessors ([`path`](Self::path),
92/// [`canonical`](Self::canonical), [`tags`](Self::tags),
93/// [`durability`](Self::durability), [`is_stable`](Self::is_stable)). This
94/// hides the internal `Vec<PathTag>` representation so it can change without
95/// breaking the public API.
96#[derive(Debug, Clone)]
97pub struct Candidate {
98    path: PathBuf,
99    canonical: PathBuf,
100    tags: Vec<PathTag>,
101}
102
103/// Policy for ranking candidates returned by [`rank_candidates`].
104///
105/// The two policies differ in whether binary identity or path stability
106/// is the primary criterion. See [`rank_candidates`] for usage.
107#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
108#[non_exhaustive]
109pub enum ScoringPolicy {
110    /// Prefer the candidate that points to the **same binary** as the input
111    /// (by canonical path or content), then break ties by path stability.
112    ///
113    /// This is the default and is appropriate for service registration, where
114    /// the exact same binary file must be referenced.
115    #[default]
116    SameBinary,
117    /// Prefer the candidate with the most **stable path**, then break ties by
118    /// binary identity.
119    ///
120    /// Useful for configuration files that should survive binary upgrades
121    /// (the path keeps working even if the underlying file changes).
122    Stable,
123}
124
125impl Candidate {
126    /// The path of this candidate as discovered (input, PATH entry, symlink
127    /// target, etc.). Not necessarily canonicalized.
128    pub fn path(&self) -> &Path {
129        &self.path
130    }
131
132    /// The canonical path of this candidate.
133    ///
134    /// When [`std::fs::canonicalize`] succeeds this is the real (symlink- and
135    /// `..`-resolved) path. When it fails, it falls back to the candidate's
136    /// own path, so this is **not guaranteed** to always be a realpath.
137    pub fn canonical(&self) -> &Path {
138        &self.canonical
139    }
140
141    /// The observed tags describing this candidate.
142    ///
143    /// Tag order is **not guaranteed** ([`PathTag::Input`] /
144    /// [`PathTag::InPathEnv`] happen to lead, but other tags follow in
145    /// implementation order). Test membership with
146    /// `tags().contains(...)` / `tags().iter().any(...)`, not by position.
147    pub fn tags(&self) -> &[PathTag] {
148        &self.tags
149    }
150
151    /// The [`Durability`] of this candidate's [`path`](Self::path): whether the
152    /// path string can be pinned into a service definition and survive
153    /// rebuild / upgrade / reboot.
154    ///
155    /// Judged per candidate, so the reference path and the canonical path of
156    /// the same binary can differ (e.g. `/opt/homebrew/bin/git` is
157    /// [`Durable`](Durability::Durable) while
158    /// `/opt/homebrew/Cellar/git/2.44.0/bin/git` is
159    /// [`NotDurable`](Durability::NotDurable)). See [`Durability`] for the
160    /// judging model and its known limitations.
161    pub fn durability(&self) -> Durability {
162        durability::judge(&self.path)
163    }
164
165    /// Whether this candidate is *durable-to-pin*: `true` iff
166    /// [`durability`](Self::durability) is [`Durability::Durable`].
167    ///
168    /// [`Durability::Unknown`] is treated as not stable (safe side).
169    pub fn is_stable(&self) -> bool {
170        matches!(self.durability(), Durability::Durable)
171    }
172
173    /// Ranking score for a [`ScoringPolicy`]. Internal: the absolute value is
174    /// policy-dependent and not a stable ordering across policies, so it is not
175    /// part of the public API. Public ranking is expressed by
176    /// [`rank_candidates`].
177    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        // Preference tier within a score band: this is *not* durability
192        // (DR-016). It expresses how preferred a candidate is among same-band
193        // peers, distinct from "can this path be baked in" (`durability()`).
194        //
195        // Tiers are 0 / 1 / 3; tier 2 is intentionally left as a gap reserved
196        // for a future intermediate category, so existing tiers keep their
197        // relative spacing without a re-numbering churn.
198        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    /// PATH discovery order from the [`PathTag::InPathEnv`] tag, or
234    /// [`usize::MAX`] if this candidate did not come from PATH. Internal: used
235    /// for deterministic tie-breaking in [`rank_candidates`].
236    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    /// Construct a candidate from its parts. Internal only.
247    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    /// Test-only constructor for building candidates from tags directly.
259    pub(crate) fn for_test(path: PathBuf, canonical: PathBuf, tags: Vec<PathTag>) -> Self {
260        Candidate::new(path, canonical, tags)
261    }
262}
263
264/// Normalize a path for deduplication (case insensitive on Windows)
265fn 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
276/// Normalize a path by resolving `.` and `..` components without touching symlinks.
277fn 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    // Relative
296    if !path.is_absolute() {
297        tags.push(PathTag::Relative);
298    }
299
300    // NonNormalized
301    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    // SymlinkTo
317    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    // Shim
323    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    // Canonical
343    let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
344
345    // SameCanonical / SameContent / DifferentBinary
346    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    // ManagedBy
355    if let Some(info) = path_analysis::detect_version_manager(path) {
356        tags.push(PathTag::ManagedBy(info.name));
357    }
358
359    // BuildOutput
360    if path_analysis::is_build_output(path) {
361        tags.push(PathTag::BuildOutput);
362    }
363
364    // Ephemeral
365    if path_analysis::is_ephemeral(path) {
366        tags.push(PathTag::Ephemeral);
367    }
368
369    // NotExecutable: no execute permission, or bare relative path (no ./ or / prefix)
370    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
386/// Discover all candidate locations for `binary`, using `path_env` as the PATH
387/// to search.
388///
389/// This is *discovery only*: candidates are returned in deterministic order
390/// (input-derived variants first, then PATH matches in discovery order). Use
391/// [`rank_candidates`] to order them by a [`ScoringPolicy`].
392///
393/// On success the returned `Vec` is **never empty**: the input itself is always
394/// emitted as a candidate. An empty result is impossible — failures surface as
395/// [`Err`] ([`Error::NotFound`] / [`Error::NotAFile`] / [`Error::NotInPath`]
396/// etc.) instead.
397///
398/// # `path_env`
399///
400/// `path_env` is the PATH-equivalent search list, not a full environment.
401///
402/// - `None` does **not** mean "skip PATH search". If `binary` is a bare command
403///   name (no path separator) and `path_env` is `None`, resolution fails with
404///   [`Error::NotInPath`]. If `binary` is an explicit path, input-derived
405///   candidates are still returned with `None`.
406/// - On Windows, `PATHEXT` is **not** taken from this argument; it is read
407///   directly from the process environment via [`std::env::var`]. See the
408///   Design rationale in the source. Full env injection (PATHEXT as an
409///   argument) is intentionally deferred (YAGNI).
410pub fn find_candidates_with_path_env(
411    binary: &Path,
412    path_env: Option<OsString>,
413) -> Result<Vec<Candidate>, Error> {
414    // Command name resolution: if no path separator, look up in PATH
415    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                    // Try exact name first
427                    let exact = dir.join(name);
428                    if path_analysis::is_executable(&exact) {
429                        return Some(exact);
430                    }
431                    // On Windows, also try with PATHEXT extensions.
432                    // Design rationale: PATHEXT is read directly from the
433                    // process environment rather than passed as an argument.
434                    // `find_candidates_with_path_env` only injects PATH; full
435                    // env injection (PATHEXT as a parameter) is deferred as
436                    // YAGNI (DR-015 Decision 7).
437                    #[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    // Verify input exists and is a file
462    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    // Compute input canonical path
471    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    // Generate candidates from the input path itself:
483    // 1. Raw input (as-is)
484    {
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    // 2. ./ or .\ prefixed (bare relative path -> explicit relative path, if file exists)
492    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    // 3. Absolutized (relative -> absolute, without resolving symlinks)
512    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    // 4. Canonical (all symlinks resolved)
525    {
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    // 5. Symlink target (one level of resolution, if input is a symlink)
535    if let Ok(link_target) = fs::read_link(binary) {
536        let link_target = if link_target.is_relative() {
537            // Join against the symlink's directory, then normalize away `.`/`..`
538            // (without touching symlinks) so the result is consistent with the
539            // absolutized candidate path (variant 3) and dedups correctly. A
540            // raw join can leave `..` components that would otherwise add a
541            // spurious NonNormalized tag and miss dedup.
542            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    // Search PATH for same-name binaries
559    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            // On Windows, also try PATHEXT extensions
565            #[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                // Skip duplicate PATH entries (same directory appearing multiple times in PATH)
583                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
597/// Discover all candidate locations for `binary`, searching the process `PATH`.
598///
599/// This is *discovery only* and returns candidates in deterministic
600/// (PATH discovery) order. Use [`rank_candidates`] to order them by a
601/// [`ScoringPolicy`]. A thin wrapper over [`find_candidates_with_path_env`]
602/// that reads `PATH` from the process environment.
603///
604/// On success the returned `Vec` is **never empty** (see
605/// [`find_candidates_with_path_env`]).
606pub fn find_candidates(binary: &Path) -> Result<Vec<Candidate>, Error> {
607    find_candidates_with_path_env(binary, env::var_os("PATH"))
608}
609
610/// Sort `candidates` in place by score descending, with PATH discovery order
611/// ascending as a deterministic tie-breaker.
612///
613/// `score` is policy-dependent and internal; ranking is expressed solely
614/// through this ordering. Takes `&mut [Candidate]` (Rust slice-sort idiom) so
615/// the caller keeps ownership of its `Vec`.
616pub 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
623/// Resolve the single best candidate for `binary` under `policy`.
624///
625/// Convenience composition of [`find_candidates`] + [`rank_candidates`]:
626/// returns the top-ranked candidate. Because `find_candidates` is non-empty on
627/// success, this always returns a tagged candidate when it returns [`Ok`]
628/// (never a fabricated tagless one).
629pub 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    // `find_candidates` guarantees a non-empty Vec on success (the input is
633    // always emitted), so the first element exists.
634    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    // --- score() tests ---
647
648    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        // SameCanonical + InPathEnv should be the highest score in SameBinary policy
655        let c = make_candidate(vec![PathTag::SameCanonical, PathTag::InPathEnv(0)]);
656        let score = c.score(ScoringPolicy::SameBinary);
657        // binary=3, stability=3, in_path=5 => 3*1000 + 3*10 + 5 = 3035
658        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        // binary=2, stability=3, in_path=5 => 2*1000 + 3*10 + 5 = 2035
666        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        // binary=0, stability=3, in_path=5 => 0 + 30 + 5 = 35
674        assert_eq!(score, 35);
675    }
676
677    #[test]
678    fn test_score_stable_policy_stable_path_wins() {
679        // In Stable policy, a DifferentBinary on stable path beats SameCanonical on unstable
680        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        // stable_different: stability=3, binary=0 => 3*1000 + 0*10 + 5 = 3005
690        // unstable_same: stability=0, binary=3 => 0*1000 + 3*10 + 5 = 35
691        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        // 3*1000 + 3*10 + 5 - 3 = 3032
705        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        // 3*1000 + 3*10 + 5 - 2 = 3033
717        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        // 3*1000 + 3*10 + 5 - 3 - 2 = 3030
730        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        // binary=3, stability=0, in_path=5 => 3*1000 + 0 + 5 = 3005
742        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        // binary=3, stability=0, in_path=5 => 3*1000 + 0 + 5 = 3005
754        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
766        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
778        assert_eq!(score, 3015);
779    }
780
781    #[test]
782    fn test_score_managed_and_shim_both_present() {
783        // Both ManagedBy and Shim: stability = 1 (same as having just one)
784        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        // binary=3, stability=1, in_path=5 => 3*1000 + 1*10 + 5 = 3015
792        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        // binary=3, stability=3, in_path=0 => 3*1000 + 3*10 + 0 = 3030
800        assert_eq!(score, 3030);
801    }
802
803    // --- find_candidates_with_path_env tests ---
804
805    #[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        // Should have at least 2 candidates: input (+ derived) + symlink from PATH
864        assert!(candidates.len() >= 2);
865
866        // The symlink candidate should have SameCanonical tag
867        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        // At least the input candidate (+ possibly canonical variant)
909        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        // Input candidate should NOT have InPathEnv
941        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        // Create a copy (not symlink) with identical content
970        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        // Should NOT have SameCanonical since they are different files
991        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        // stable_dir has symlink (SameCanonical), other_dir has different binary
999        let path_env = f.make_path(&[&f.other_dir, &f.stable_dir]);
1000
1001        // find_candidates_with_path_env is discovery-only (unsorted); ranking
1002        // is a separate step.
1003        let mut candidates = find_candidates_with_path_env(&f.real_binary, Some(path_env)).unwrap();
1004        rank_candidates(&mut candidates, ScoringPolicy::SameBinary);
1005
1006        // Verify scores are in descending order after ranking
1007        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        // When input path is in PATH, it should not appear twice
1026        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        // Same directory appears twice in PATH
1044        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        // Stable policy: stable DifferentBinary > unstable SameCanonical
1086        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        // But SameBinary policy reverses this
1090        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        // Real binary named "jj-worktree"
1130        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        // Symlink "git" -> "jj-worktree" (name mismatch = shim)
1135        let shim_link = shim_dir.join("git");
1136        symlink(&real_binary, &shim_link).unwrap();
1137
1138        // Also create a real "git" binary for input
1139        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        // "python3" -> "python3.12" (prefix match = NOT shim)
1168        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    // --- accessors / durability / 3-layer API ---
1185
1186    // The durable path (/opt/homebrew/bin/git) is a Unix absolute path.
1187    // On Windows, Path::is_absolute() returns false for paths without a drive
1188    // letter, so is_durable_direct_dir() returns false and the path falls
1189    // through to Unknown instead of Durable → guard with #[cfg(unix)].
1190    // The versioned path (/opt/homebrew/Cellar/…) matches VERSIONED_INSTALL_PATTERNS
1191    // via substring ("/Cellar/") and would be NotDurable on any OS, but since
1192    // it lives in the same test function it is guarded together.
1193    #[cfg(unix)]
1194    #[test]
1195    fn test_durability_accessor_per_candidate() {
1196        // Reference surface is durable; versioned canonical is not.
1197        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        // Discovery order is input-derived first, then PATH order. Not sorted
1241        // by score. The input candidate stays at index 0.
1242        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        // Explicit path with empty PATH search: still returns the input.
1253        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        // resolve_stable_path uses the process PATH; the input is an explicit
1267        // path so a tagged candidate is always returned.
1268        let best = resolve_stable_path(&f.real_binary, ScoringPolicy::SameBinary).unwrap();
1269        assert!(!best.tags().is_empty());
1270    }
1271}