Skip to main content

stable_which/
durability.rs

1//! Durability judging: can a path be pinned into a service definition?
2//!
3//! See `DR-016` (durable-to-pin model) in the repository's `docs/decisions/`.
4//! This module is internal; the public surface is [`crate::Durability`] and
5//! [`crate::Candidate::durability`]. (The DR reference is an in-repo document,
6//! not a docs.rs link.)
7
8use std::path::{Component, Path};
9
10use crate::path_analysis;
11
12/// Whether a path string keeps pointing at the same (logical) target across
13/// rebuild / upgrade / reboot.
14///
15/// This is a *durable-to-pin* judgement: a [`Durable`](Durability::Durable)
16/// path may be baked into a launchd plist / systemd unit and is expected to
17/// survive upgrades and reboots. Judgement is **per candidate** — for the same
18/// binary, the reference path (`/opt/homebrew/bin/git`) can be
19/// [`Durable`](Durability::Durable) while its canonical realpath
20/// (`/opt/homebrew/Cellar/git/2.44.0/bin/git`) is
21/// [`NotDurable`](Durability::NotDurable).
22///
23/// # Judging model (allow-list)
24///
25/// 1. `NotDurable` is decided first: versioned-install / ephemeral /
26///    build-output / project-local patterns.
27/// 2. Otherwise, if the path is under a known durable location (the
28///    "environment-wide reference surfaces": standard system bins, profile
29///    bins matched by their exact structure, shims at standard HOME-anchored
30///    locations, and HOME-anchored direct-install dirs such as
31///    `~/.cargo/bin`), it is `Durable`.
32/// 3. Otherwise `Unknown` (treated as not-durable by
33///    [`Candidate::is_stable`](crate::Candidate::is_stable)).
34///
35/// User dropbox directories (`~/bin`, `~/.local/bin`) are deliberately *not*
36/// in the durable allow-list: they mix shebang-embedded scripts and versioned
37/// symlinks, so they fall through to `Unknown`. `~/.cargo/bin` is not such a
38/// dropbox: `cargo install` overwrites the binary in place at the same path
39/// on every install/upgrade (no per-tool versioned symlink), so it is
40/// included in the allow-list.
41///
42/// # Platform support
43///
44/// The durable location allow-list (step 2) is Unix-only: all entries in
45/// [`DURABLE_DIRECT_DIRS`] and the `/etc/profiles/per-user/<user>/bin/<file>`
46/// shape are Unix absolute paths (`/usr/bin`, `/opt/homebrew/bin`, etc.).
47/// On Windows, [`Path::is_absolute`] returns `false` for these Unix paths
48/// (no drive letter), so [`is_durable_direct_dir`] and
49/// [`is_etc_profiles_per_user_bin`] always return `false`.
50/// As a result, **on Windows all paths that do not match a `NotDurable`
51/// pattern fall through to `Unknown`** (the safe side).
52/// Windows-native durable surfaces (`C:\Windows\System32`, scoop/chocolatey/
53/// winget `bin` directories, PowerShell profile paths, etc.) are not yet in
54/// the allow-list and are planned for 0.5.x. See `docs/issue/` for the
55/// tracking issue.
56///
57/// # Known limitations
58///
59/// Judgement is path-string based and cannot detect:
60///
61/// - **shebang-embedded versioned dependencies**: e.g. a script in a standard
62///   reference surface whose shebang points at a versioned interpreter
63///   (`cpython@<ver>`). Such a path may be judged `Durable` yet break on
64///   upgrade. This is why shebang-prone user dropboxes are excluded from the
65///   allow-list (see above), but the case cannot be ruled out entirely.
66/// - **environment-dependent paths**: e.g. `~/.nix-profile` is alive in some
67///   environments and dead in others; durability cannot be confirmed without
68///   inspecting the realpath.
69/// - **non-UTF-8 paths**: pattern matching goes through
70///   [`Path::to_string_lossy`], so a path containing non-UTF-8 bytes in a
71///   matched segment may compare unexpectedly; such paths typically fall
72///   through to `Unknown` (safe side).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub enum Durability {
76    /// The path string keeps pointing at the same entity (or same logical
77    /// target) across rebuild / upgrade / reboot.
78    Durable,
79    /// Versioned-install / ephemeral / build-output / project-local etc.;
80    /// baking this path into a service definition can break it.
81    NotDurable,
82    /// Matched neither a known durable nor a known not-durable pattern; cannot
83    /// decide (safe side = treated as not-durable).
84    Unknown,
85}
86
87/// Internal scope of a path. Not exposed publicly (DR-016 Decision 2):
88/// project-local is folded into [`Durability::NotDurable`].
89///
90/// Kept as an enum (rather than a bool) as a deliberate placeholder for the
91/// future: if DR-016 Decision 2's "expose scope" option is ever taken, the
92/// public `Scope` enum can grow from this internal one without a churny
93/// bool→enum migration.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95enum Scope {
96    /// Scoped to a single project (`.direnv/`, `venv/`, `node_modules/.bin/`).
97    /// A global service must not reference it.
98    ProjectLocal,
99    /// Not project-scoped (the common case).
100    Global,
101}
102
103/// Project-local path fragments. A global service should never reference these.
104const PROJECT_LOCAL_PATTERNS: &[&str] = &[
105    "/.direnv/",
106    "/node_modules/.bin/",
107    "/.venv/",
108    "/venv/bin/",
109    "/.tox/",
110];
111
112/// Versioned-install path fragments: the path embeds a version identifier, so
113/// it changes on upgrade. These are NotDurable.
114const VERSIONED_INSTALL_PATTERNS: &[&str] = &[
115    // Homebrew Cellar / Caskroom
116    "/Cellar/",
117    "/Caskroom/",
118    // mise / asdf installs
119    "/mise/installs/",
120    "/.mise/installs/",
121    "/asdf/installs/",
122    "/.asdf/installs/",
123    // nix store (hash + version in first segment)
124    "/nix/store/",
125    // version-manager versions/ trees
126    "/.nvm/versions/",
127    "/.fnm/node-versions/",
128    "/.rustup/toolchains/",
129    "/.volta/tools/",
130    "/.sdkman/candidates/",
131    "/.pyenv/versions/",
132    "/.rbenv/versions/",
133    "/.goenv/versions/",
134    "/.proto/tools/",
135    "/scoop/apps/",
136    "/chocolatey/lib/",
137    "/WinGet/Packages/",
138    // aqua internal packages
139    "/aquaproj-aqua/internal/pkgs/",
140    // app-specific versioned trees, anchored to the specific tool name to avoid
141    // false positives from an unrelated generic `/versions/` segment (findings:
142    // claude / cursor-agent each keep a `versions/<ver>/` tree).
143    "/claude/versions/",
144    "/cursor-agent/versions/",
145    // rye cpython interpreters
146    "/cpython@",
147];
148
149/// Durable directory surfaces where the binary lives **directly** in the dir
150/// (`<dir>/<file>`). These are matched by exact parent-directory equality, not
151/// substring, so `/usr/local/binutils/foo` or `/opt/homebrew/binfoo/git` do
152/// not match.
153///
154/// Design rationale: `/usr/local/bin` is included because it is a standard,
155/// environment-wide reference surface populated by `make install`, distro
156/// packages, and `brew link` symlinks — the reference path is stable across
157/// upgrades. The residual risk is a tool that itself places a *versioned
158/// symlink* directly in `/usr/local/bin` (rare); that case is a known
159/// limitation of path-string judgement (a `Durable` verdict that could still
160/// break) and is documented on [`Durability`].
161const DURABLE_DIRECT_DIRS: &[&str] = &[
162    // standard system bins
163    "/usr/bin",
164    "/bin",
165    "/usr/local/bin",
166    "/usr/sbin",
167    "/sbin",
168    // homebrew profile bins (the symlink surface, not Cellar)
169    "/opt/homebrew/bin",
170    "/opt/homebrew/sbin",
171    // nix-darwin / Determinate Nix profile surfaces
172    "/run/current-system/sw/bin",
173    "/nix/var/nix/profiles/default/bin",
174];
175
176/// Standard shim directory suffixes (HOME-anchored). A shim path is treated as
177/// durable only when it both matches one of these and is anchored under the
178/// resolved HOME, so a project-local `/repo/.mise/shims/tool` does not qualify.
179const STANDARD_SHIM_SUFFIXES: &[&str] = &[
180    "/.local/share/mise/shims",
181    "/.mise/shims",
182    "/.asdf/shims",
183    "/.pyenv/shims",
184    "/.rbenv/shims",
185    "/.goenv/shims",
186    "/.proto/shims",
187];
188
189/// HOME-anchored direct-install directory suffixes: individual tools install
190/// their binary directly here (`<home><suffix>/<file>`), overwriting the same
191/// path on every install/upgrade rather than writing a versioned tree, so the
192/// path string stays durable across upgrades.
193///
194/// Design rationale: each entry is a directory whose owning installer
195/// (`cargo install` / `go install` / `moon upgrade`) writes the binary in
196/// place at the same path on every install/upgrade — no versioned tree, so
197/// the path string keeps pointing at the current binary (findings
198/// `2026-06-13-binary-path-durability-matrix` fact 7; promoted per DR-016
199/// Decision 5's `Unknown → Durable` refinement path). rustup's
200/// toolchain-proxy binaries (`rustc`, `cargo`, `rustfmt`, ...) also live in
201/// `~/.cargo/bin` and redirect internally to the active toolchain without
202/// moving this path. Unlike `~/.local/bin` (Decision 3 of DR-016), these
203/// directories are populated by a single, well-known installer rather than an
204/// ad-hoc mix of shebang scripts and versioned symlinks.
205///
206/// Residual risk (same class as the `/usr/local/bin` note on
207/// [`DURABLE_DIRECT_DIRS`]): these are user-writable directories on PATH, so
208/// an unrelated tool or a manual `ln -s` can place a *versioned symlink* here
209/// (e.g. via `cargo-binstall` or a curl-pipe installer). Such a path would be
210/// judged `Durable` yet break on upgrade — a known limitation of path-string
211/// judgement, documented on [`Durability`].
212const HOME_ANCHORED_DIRECT_DIRS: &[&str] = &[
213    "/.cargo/bin", // cargo install / rustup proxies
214    "/go/bin",     // go install (default GOPATH)
215    "/.moon/bin",  // moon (MoonBit toolchain)
216];
217
218/// Normalize a path string to forward slashes for pattern matching.
219fn norm(path: &Path) -> String {
220    path.to_string_lossy().replace('\\', "/")
221}
222
223/// Detect whether a path is project-local (scoped to a single project).
224fn scope_of(path: &Path) -> Scope {
225    let s = norm(path);
226    if PROJECT_LOCAL_PATTERNS.iter().any(|p| s.contains(p)) {
227        Scope::ProjectLocal
228    } else {
229        Scope::Global
230    }
231}
232
233/// Detect whether a path embeds a version identifier (versioned install).
234fn is_versioned_install(path: &Path) -> bool {
235    let s = norm(path);
236    VERSIONED_INSTALL_PATTERNS.iter().any(|p| s.contains(p))
237}
238
239/// Collect the normal (named) path components of `path` as strings.
240///
241/// Only [`Component::Normal`] segments are kept; root / prefix / `.` / `..`
242/// are dropped. A path with any `..` is considered un-normalized by callers and
243/// will not match the strict structural checks below.
244fn normal_components(path: &Path) -> Vec<String> {
245    path.components()
246        .filter_map(|c| match c {
247            Component::Normal(os) => Some(os.to_string_lossy().into_owned()),
248            _ => None,
249        })
250        .collect()
251}
252
253/// `/etc/profiles/per-user/<user>/bin/<file>` (home-manager profile surface).
254///
255/// Strictly require the exact structure: components `etc`, `profiles`,
256/// `per-user`, `<user>`, `bin`, `<file>` with nothing in between and the file
257/// as the last component. This rejects
258/// `/etc/profiles/per-user/alice/lib/python/bin/foo` and a non-absolute
259/// `home/u/etc/profiles/per-user/alice/bin/foo`.
260fn is_etc_profiles_per_user_bin(path: &Path) -> bool {
261    if !path.is_absolute() {
262        return false;
263    }
264    let c = normal_components(path);
265    c.len() == 6
266        && c[0] == "etc"
267        && c[1] == "profiles"
268        && c[2] == "per-user"
269        // c[3] is the user name (any single segment)
270        && c[4] == "bin"
271    // c[5] is the file name (last component); len()==6 guarantees terminal.
272}
273
274/// Whether the binary lives directly in one of [`DURABLE_DIRECT_DIRS`]
275/// (`<dir>/<file>`), matched by exact parent equality on an absolute path.
276fn is_durable_direct_dir(path: &Path) -> bool {
277    if !path.is_absolute() {
278        return false;
279    }
280    let Some(parent) = path.parent() else {
281        return false;
282    };
283    // file_name() must exist (so `path` is `<dir>/<file>`, not `<dir>/`).
284    if path.file_name().is_none() {
285        return false;
286    }
287    let parent_s = norm(parent);
288    DURABLE_DIRECT_DIRS.iter().any(|&d| parent_s == d)
289}
290
291/// Whether `path` is `<home><suffix>/<file>` for one of `suffixes`:
292/// HOME-anchored, with exactly one of the given suffix directories, then a
293/// single file segment (no further `/`). Shared by [`is_standard_shim`] and
294/// [`is_home_anchored_direct_dir`].
295///
296/// `home` is the resolved HOME directory; when `None`, HOME-anchoring cannot
297/// be confirmed and the function returns `false` (→ `Unknown`).
298fn is_home_anchored_suffix_dir(path: &Path, home: Option<&Path>, suffixes: &[&str]) -> bool {
299    let Some(home) = home else {
300        return false;
301    };
302    if !path.is_absolute() {
303        return false;
304    }
305    let home_s = {
306        let mut h = norm(home);
307        while h.ends_with('/') {
308            h.pop();
309        }
310        h
311    };
312    if home_s.is_empty() {
313        return false;
314    }
315    let s = norm(path);
316    suffixes.iter().any(|suffix| {
317        let with_sep = format!("{home_s}{suffix}/");
318        // `s` starts with `<home><suffix>/` and the remainder is a single file
319        // segment (no further `/`).
320        s.strip_prefix(&with_sep)
321            .is_some_and(|rest| !rest.is_empty() && !rest.contains('/'))
322    })
323}
324
325/// Whether `path` is a shim at a standard HOME-anchored location.
326fn is_standard_shim(path: &Path, home: Option<&Path>) -> bool {
327    // The shim must be recognized as a shim by the shared detector first.
328    path_analysis::is_shim_path(path)
329        && is_home_anchored_suffix_dir(path, home, STANDARD_SHIM_SUFFIXES)
330}
331
332/// Whether `path` is a direct install destination at a standard HOME-anchored
333/// location (`~/.cargo/bin` etc; see [`HOME_ANCHORED_DIRECT_DIRS`]).
334fn is_home_anchored_direct_dir(path: &Path, home: Option<&Path>) -> bool {
335    is_home_anchored_suffix_dir(path, home, HOME_ANCHORED_DIRECT_DIRS)
336}
337
338/// Detect whether a path lives at a known durable reference surface.
339///
340/// Each surface is matched by its exact structure (direct-dir parent equality,
341/// the strict per-user profile shape, or a HOME-anchored standard shim or
342/// direct-install dir), never by loose substring. Unrecognized locations fall
343/// through to `Unknown`.
344fn is_durable_location(path: &Path, home: Option<&Path>) -> bool {
345    is_durable_direct_dir(path)
346        || is_etc_profiles_per_user_bin(path)
347        || is_standard_shim(path, home)
348        || is_home_anchored_direct_dir(path, home)
349}
350
351/// Judge the durability of a candidate path, resolving HOME from the process
352/// environment. See [`Durability`] for the judging model and platform notes.
353///
354/// On Windows, the durable location allow-list is not populated (all entries
355/// are Unix absolute paths), so paths that do not match a `NotDurable` pattern
356/// fall through to `Unknown` (safe side). Windows-native durable surfaces are
357/// planned for 0.5.x.
358pub fn judge(path: &Path) -> Durability {
359    let home = std::env::var_os("HOME").map(std::path::PathBuf::from);
360    judge_with_home(path, home.as_deref())
361}
362
363/// Judge durability with an explicit HOME (for testability). See [`Durability`].
364fn judge_with_home(path: &Path, home: Option<&Path>) -> Durability {
365    // 1. NotDurable is decided first.
366    if is_versioned_install(path)
367        || path_analysis::is_ephemeral(path)
368        || path_analysis::is_build_output(path)
369        || scope_of(path) == Scope::ProjectLocal
370    {
371        return Durability::NotDurable;
372    }
373
374    // 2. Known durable reference surfaces.
375    if is_durable_location(path, home) {
376        return Durability::Durable;
377    }
378
379    // 3. Fall through: cannot decide.
380    Durability::Unknown
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use std::path::PathBuf;
387
388    const HOME: &str = "/home/u";
389
390    fn judge_str(s: &str) -> Durability {
391        // Most tests anchor shims under "/home/u".
392        judge_with_home(Path::new(s), Some(Path::new(HOME)))
393    }
394
395    // --- NotDurable: versioned-install (findings real examples) ---
396
397    #[test]
398    fn homebrew_cellar_is_not_durable() {
399        assert_eq!(
400            judge_str("/opt/homebrew/Cellar/node/26.0.0/bin/node"),
401            Durability::NotDurable
402        );
403    }
404
405    #[test]
406    fn nix_store_is_not_durable() {
407        assert_eq!(
408            judge_str("/nix/store/abc123hash-git-2.54.0/bin/git"),
409            Durability::NotDurable
410        );
411    }
412
413    #[test]
414    fn mise_installs_is_not_durable() {
415        assert_eq!(
416            judge_str("/home/u/.local/share/mise/installs/node/22.22.0/bin/node"),
417            Durability::NotDurable
418        );
419    }
420
421    #[test]
422    fn claude_versions_is_not_durable() {
423        assert_eq!(
424            judge_str("/home/u/.local/share/claude/versions/2.1.177/bin/claude"),
425            Durability::NotDurable
426        );
427    }
428
429    #[test]
430    fn cursor_agent_versions_is_not_durable() {
431        assert_eq!(
432            judge_str(
433                "/home/u/.local/share/cursor-agent/versions/2025.09.17-25b418f/bin/cursor-agent"
434            ),
435            Durability::NotDurable
436        );
437    }
438
439    #[test]
440    fn generic_versions_segment_is_not_versioned_install() {
441        // H: the generic `/versions/` pattern was removed. An unrelated dir
442        // named "versions" should NOT be classified as a versioned install
443        // (falls through to Unknown here, not NotDurable-by-versioned).
444        assert_eq!(
445            judge_str("/home/u/myapp/versions/data/tool"),
446            Durability::Unknown
447        );
448    }
449
450    #[test]
451    fn rye_cpython_is_not_durable() {
452        assert_eq!(
453            judge_str("/home/u/.dotfiles/cache/rye/py/cpython@3.12.8/bin/python"),
454            Durability::NotDurable
455        );
456    }
457
458    #[test]
459    fn aqua_internal_pkgs_is_not_durable() {
460        assert_eq!(
461            judge_str("/home/u/.local/share/aquaproj-aqua/internal/pkgs/foo/v2.56.2/bin/aqua"),
462            Durability::NotDurable
463        );
464    }
465
466    // --- NotDurable: ephemeral / build-output / project-local ---
467
468    #[test]
469    fn cache_dir_is_not_durable() {
470        assert_eq!(
471            judge_str("/home/u/.cache/jj-worktree/bin/git"),
472            Durability::NotDurable
473        );
474    }
475
476    #[test]
477    fn build_output_is_not_durable() {
478        assert_eq!(
479            judge_str("/home/u/project/target/release/myapp"),
480            Durability::NotDurable
481        );
482    }
483
484    #[test]
485    fn direnv_is_not_durable() {
486        assert_eq!(
487            judge_str("/home/u/project/.direnv/python-3.12/bin/python"),
488            Durability::NotDurable
489        );
490    }
491
492    #[test]
493    fn venv_is_not_durable() {
494        assert_eq!(
495            judge_str("/home/u/project/.venv/bin/python"),
496            Durability::NotDurable
497        );
498    }
499
500    #[test]
501    fn node_modules_bin_is_not_durable() {
502        assert_eq!(
503            judge_str("/home/u/project/node_modules/.bin/eslint"),
504            Durability::NotDurable
505        );
506    }
507
508    // --- Durable: environment-wide reference surfaces ---
509    // These tests use Unix absolute paths (/usr/bin, /opt/homebrew/bin, etc.)
510    // and expect Durable.  On Windows, Path::is_absolute() returns false for
511    // paths without a drive letter, so is_durable_direct_dir() and
512    // is_etc_profiles_per_user_bin() always return false and these paths would
513    // fall through to Unknown.  Guard with #[cfg(unix)].
514
515    #[cfg(unix)]
516    #[test]
517    fn homebrew_bin_is_durable() {
518        assert_eq!(judge_str("/opt/homebrew/bin/node"), Durability::Durable);
519    }
520
521    #[cfg(unix)]
522    #[test]
523    fn homebrew_sbin_is_durable() {
524        assert_eq!(judge_str("/opt/homebrew/sbin/foo"), Durability::Durable);
525    }
526
527    #[cfg(unix)]
528    #[test]
529    fn usr_bin_is_durable() {
530        assert_eq!(judge_str("/usr/bin/git"), Durability::Durable);
531    }
532
533    #[cfg(unix)]
534    #[test]
535    fn usr_sbin_is_durable() {
536        assert_eq!(judge_str("/usr/sbin/foo"), Durability::Durable);
537    }
538
539    #[cfg(unix)]
540    #[test]
541    fn sbin_is_durable() {
542        assert_eq!(judge_str("/sbin/init"), Durability::Durable);
543    }
544
545    #[cfg(unix)]
546    #[test]
547    fn bin_is_durable() {
548        assert_eq!(judge_str("/bin/sh"), Durability::Durable);
549    }
550
551    #[cfg(unix)]
552    #[test]
553    fn usr_local_bin_is_durable() {
554        assert_eq!(judge_str("/usr/local/bin/node"), Durability::Durable);
555    }
556
557    #[cfg(unix)]
558    #[test]
559    fn mise_shim_is_durable() {
560        assert_eq!(
561            judge_str("/home/u/.local/share/mise/shims/node"),
562            Durability::Durable
563        );
564    }
565
566    #[cfg(unix)]
567    #[test]
568    fn run_current_system_is_durable() {
569        assert_eq!(
570            judge_str("/run/current-system/sw/bin/git"),
571            Durability::Durable
572        );
573    }
574
575    #[cfg(unix)]
576    #[test]
577    fn etc_profiles_per_user_is_durable() {
578        assert_eq!(
579            judge_str("/etc/profiles/per-user/alice/bin/curl"),
580            Durability::Durable
581        );
582    }
583
584    #[cfg(unix)]
585    #[test]
586    fn nix_default_profile_is_durable() {
587        assert_eq!(
588            judge_str("/nix/var/nix/profiles/default/bin/nix"),
589            Durability::Durable
590        );
591    }
592
593    // `cargo install` overwrites the binary in place at the same path on
594    // every install/upgrade (no versioned tree), so ~/.cargo/bin is a durable
595    // reference surface (promoted from Unknown; see HOME_ANCHORED_DIRECT_DIRS).
596    #[cfg(unix)]
597    #[test]
598    fn cargo_bin_is_durable() {
599        assert_eq!(judge_str("/home/u/.cargo/bin/ripgrep"), Durability::Durable);
600    }
601
602    // `go install` writes to $GOPATH/bin (default ~/go/bin), overwriting the
603    // same path on every install — same in-place model as cargo (findings
604    // fact 7), so the default location is a durable reference surface.
605    #[cfg(unix)]
606    #[test]
607    fn go_bin_is_durable() {
608        assert_eq!(judge_str("/home/u/go/bin/gopls"), Durability::Durable);
609    }
610
611    // moon (MoonBit) installs its toolchain binaries at ~/.moon/bin and
612    // upgrades in place at the same path — same in-place model (findings
613    // fact 7), so it is a durable reference surface.
614    #[cfg(unix)]
615    #[test]
616    fn moon_bin_is_durable() {
617        assert_eq!(judge_str("/home/u/.moon/bin/moon"), Durability::Durable);
618    }
619
620    // --- A: strict per-user profile structure (false-positive regression) ---
621
622    #[test]
623    fn etc_profiles_per_user_deep_is_not_durable() {
624        // Deeper than `<user>/bin/<file>`: must NOT be Durable.
625        assert_eq!(
626            judge_with_home(
627                Path::new("/etc/profiles/per-user/alice/lib/python/bin/foo"),
628                Some(Path::new(HOME))
629            ),
630            Durability::Unknown
631        );
632    }
633
634    #[test]
635    fn etc_profiles_per_user_relative_is_not_durable() {
636        // Non-absolute lookalike must NOT match.
637        assert_eq!(
638            judge_with_home(
639                Path::new("home/u/etc/profiles/per-user/alice/bin/foo"),
640                Some(Path::new(HOME))
641            ),
642            Durability::Unknown
643        );
644    }
645
646    // --- A/E: direct-dir exact match (substring false-positive regression) ---
647
648    #[test]
649    fn usr_local_binutils_is_unknown() {
650        // `/usr/local/binutils/foo` must NOT match `/usr/local/bin`.
651        assert_eq!(judge_str("/usr/local/binutils/foo"), Durability::Unknown);
652    }
653
654    #[test]
655    fn opt_homebrew_binfoo_is_unknown() {
656        // `/opt/homebrew/binfoo/git` must NOT match `/opt/homebrew/bin`.
657        assert_eq!(judge_str("/opt/homebrew/binfoo/git"), Durability::Unknown);
658    }
659
660    #[test]
661    fn usr_bin_nested_is_unknown() {
662        // A file nested below /usr/bin (not direct child) must NOT match.
663        assert_eq!(judge_str("/usr/bin/subdir/foo"), Durability::Unknown);
664    }
665
666    // --- B: project/user-local shim must not be Durable ---
667
668    #[test]
669    fn project_local_mise_shim_is_unknown() {
670        // `/repo/.mise/shims/tool` is shim-shaped but NOT HOME-anchored.
671        assert_eq!(
672            judge_with_home(Path::new("/repo/.mise/shims/tool"), Some(Path::new(HOME))),
673            Durability::Unknown
674        );
675    }
676
677    #[test]
678    fn shim_without_home_is_unknown() {
679        // HOME unknown → cannot confirm standard shim → Unknown.
680        assert_eq!(
681            judge_with_home(Path::new("/home/u/.local/share/mise/shims/node"), None),
682            Durability::Unknown
683        );
684    }
685
686    // Unix absolute path + Durable expected → guard with #[cfg(unix)].
687    #[cfg(unix)]
688    #[test]
689    fn shim_anchored_under_home_is_durable() {
690        assert_eq!(
691            judge_with_home(Path::new("/home/u/.asdf/shims/ruby"), Some(Path::new(HOME))),
692            Durability::Durable
693        );
694    }
695
696    // --- C: HOME-anchored direct dir false-positive regression ---
697    // Exercised via ~/.cargo/bin; the anchoring/structure mechanism
698    // (is_home_anchored_suffix_dir) is shared by all HOME_ANCHORED_DIRECT_DIRS
699    // entries (go/bin, .moon/bin), so per-entry duplicates add no coverage.
700
701    #[test]
702    fn project_local_cargo_bin_is_unknown() {
703        // `/repo/.cargo/bin/tool` is directly under a dir named `.cargo/bin`
704        // but is NOT anchored under the resolved HOME (project-local
705        // lookalike); must not be promoted to Durable.
706        assert_eq!(
707            judge_with_home(Path::new("/repo/.cargo/bin/tool"), Some(Path::new(HOME))),
708            Durability::Unknown
709        );
710    }
711
712    #[test]
713    fn cargo_bin_without_home_is_unknown() {
714        // HOME unknown → cannot confirm HOME-anchoring → Unknown, even though
715        // the path shape matches `.cargo/bin`.
716        assert_eq!(
717            judge_with_home(Path::new("/home/u/.cargo/bin/ripgrep"), None),
718            Durability::Unknown
719        );
720    }
721
722    #[test]
723    fn cargo_bin_nested_is_unknown() {
724        // `<home>/.cargo/bin/sub/tool` is not a direct child of `.cargo/bin`
725        // (extra `sub/` segment); the strict "single file segment" structural
726        // match must reject it.
727        assert_eq!(
728            judge_with_home(
729                Path::new("/home/u/.cargo/bin/sub/tool"),
730                Some(Path::new(HOME))
731            ),
732            Durability::Unknown
733        );
734    }
735
736    #[test]
737    fn cargo_binx_lookalike_is_unknown() {
738        // `.cargo/binx` is a similar-looking but distinct directory name; the
739        // exact-suffix match must not treat it as `.cargo/bin`.
740        assert_eq!(
741            judge_with_home(Path::new("/home/u/.cargo/binx/tool"), Some(Path::new(HOME))),
742            Durability::Unknown
743        );
744    }
745
746    // --- Unknown: user dropbox and unrecognized locations ---
747
748    #[test]
749    fn local_bin_jupyter_is_unknown() {
750        // ~/.local/bin mixes shebang/versioned, deliberately excluded.
751        assert_eq!(judge_str("/home/u/.local/bin/jupyter"), Durability::Unknown);
752    }
753
754    #[test]
755    fn home_bin_is_unknown() {
756        assert_eq!(judge_str("/home/u/bin/mytool"), Durability::Unknown);
757    }
758
759    #[test]
760    fn unrecognized_dir_is_unknown() {
761        assert_eq!(judge_str("/some/random/dir/tool"), Durability::Unknown);
762    }
763
764    // --- ordering: NotDurable wins over a durable-looking surface ---
765
766    #[test]
767    fn ephemeral_shim_is_not_durable_despite_shim() {
768        // jj-worktree cache: ephemeral + shim. NotDurable must win (decided first).
769        assert_eq!(
770            judge_str("/home/u/.cache/jj-worktree/.mise/shims/git"),
771            Durability::NotDurable
772        );
773    }
774
775    // --- O: Windows backslash normalization ---
776
777    #[test]
778    fn windows_chocolatey_lib_is_not_durable() {
779        assert_eq!(
780            judge_str(r"C:\ProgramData\chocolatey\lib\foo\bin\foo.exe"),
781            Durability::NotDurable
782        );
783    }
784
785    #[test]
786    fn windows_winget_packages_is_not_durable() {
787        assert_eq!(
788            judge_str(r"C:\Users\u\AppData\Local\Microsoft\WinGet\Packages\Foo\bin\foo.exe"),
789            Durability::NotDurable
790        );
791    }
792
793    #[test]
794    fn windows_scoop_apps_is_not_durable() {
795        assert_eq!(
796            judge_str(r"C:\Users\u\scoop\apps\foo\1.2.3\foo.exe"),
797            Durability::NotDurable
798        );
799    }
800
801    // --- helper-level direct tests ---
802
803    // The first assertion uses a Unix absolute path and expects true.
804    // On Windows, Path::is_absolute() returns false for paths without a drive
805    // letter, so is_durable_direct_dir() returns false → guard with #[cfg(unix)].
806    #[cfg(unix)]
807    #[test]
808    fn is_durable_direct_dir_exact_only() {
809        assert!(is_durable_direct_dir(&PathBuf::from("/usr/bin/git")));
810        assert!(!is_durable_direct_dir(&PathBuf::from("/usr/bin/sub/git")));
811        assert!(!is_durable_direct_dir(&PathBuf::from("usr/bin/git")));
812    }
813}