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}