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