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}