Skip to main content

stable_which/
path_analysis.rs

1use std::fs;
2use std::io::Read;
3use std::path::Path;
4
5/// Normalize path separators to '/' for pattern matching
6fn normalize_separators(path_str: &str) -> String {
7    path_str.replace('\\', "/")
8}
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct VersionManagerInfo {
12    pub name: String,
13}
14
15const INSTALL_PATTERNS: &[(&str, &str)] = &[
16    ("/mise/installs/", "mise"),
17    ("/.mise/installs/", "mise"),
18    ("/asdf/installs/", "asdf"),
19    ("/.asdf/installs/", "asdf"),
20    ("/nix/store/", "nix"),
21    ("/.nix-profile/", "nix"),
22    ("/Cellar/", "homebrew"),
23    ("/Caskroom/", "homebrew"),
24    ("/.nvm/versions/", "nvm"),
25    ("/.fnm/node-versions/", "fnm"),
26    ("/.rustup/toolchains/", "rustup"),
27    ("/.volta/tools/", "volta"),
28    ("/.sdkman/candidates/", "sdkman"),
29    ("/.pyenv/versions/", "pyenv"),
30    ("/.rbenv/versions/", "rbenv"),
31    ("/.goenv/versions/", "goenv"),
32    ("/aquaproj-aqua/internal/pkgs/", "aqua"),
33    ("/.proto/tools/", "proto"),
34    ("/scoop/apps/", "scoop"),
35    ("/chocolatey/lib/", "chocolatey"),
36    ("/WinGet/Packages/", "winget"),
37];
38
39const SHIM_PATTERNS: &[&str] = &[
40    "/mise/shims/",
41    "/.mise/shims/",
42    "/asdf/shims/",
43    "/.asdf/shims/",
44    "/.pyenv/shims/",
45    "/.rbenv/shims/",
46    "/.goenv/shims/",
47    "/.proto/shims/",
48    "/scoop/shims/",
49    "/chocolatey/bin/",
50];
51
52const BUILD_OUTPUT_PATTERNS: &[&str] = &[
53    "/target/debug/",
54    "/target/release/",
55    "/.build/debug/",
56    "/.build/release/",
57    "/dist-newstyle/",
58    "/.stack-work/",
59    "/_build/",
60    "/zig-out/",
61    "/zig-cache/",
62    "/cmake-build-",
63    "/bin/Debug/",
64    "/bin/Release/",
65    "/.dub/build/",
66    "/nimcache/",
67    "/DerivedData/",
68    "/Build/Products/",
69    "/target/wasm-gc/",
70    "/target/wasm/",
71    "/target/js/",
72];
73
74/// Detect whether a path is under a version manager's install directory.
75pub fn detect_version_manager(path: &Path) -> Option<VersionManagerInfo> {
76    let s = normalize_separators(&path.to_string_lossy());
77    for &(pattern, name) in INSTALL_PATTERNS {
78        if s.contains(pattern) {
79            return Some(VersionManagerInfo {
80                name: name.to_string(),
81            });
82        }
83    }
84    None
85}
86
87/// Detect whether a path is inside a shim directory.
88pub fn is_shim_path(path: &Path) -> bool {
89    let s = normalize_separators(&path.to_string_lossy());
90    SHIM_PATTERNS.iter().any(|pattern| s.contains(pattern))
91}
92
93/// Heuristic: if the symlink target name does not start with the candidate name,
94/// it is likely a shim dispatcher (e.g. `git` -> `jj-worktree`).
95/// Version suffixes like `python3` -> `python3.12` are not shims.
96pub fn is_shim_by_name(candidate_name: &str, symlink_target_name: &str) -> bool {
97    !symlink_target_name.starts_with(candidate_name)
98}
99
100/// Detect whether a path is inside a build output directory.
101pub fn is_build_output(path: &Path) -> bool {
102    let s = normalize_separators(&path.to_string_lossy());
103    BUILD_OUTPUT_PATTERNS
104        .iter()
105        .any(|pattern| s.contains(pattern))
106}
107
108/// Detect whether the parent directory of a path is a temporary/cache location.
109///
110/// Checks path components (split by `-`, `_`, `.`) case-insensitively for
111/// `cache`, `tmp`, `temp`, or `temporary`. Excludes the portion of the path
112/// before `.app/` to avoid false positives from macOS app bundle names.
113///
114/// Backslash separators (Windows-style paths) are normalized to `/` before
115/// analysis so that this function produces correct results when Windows paths
116/// are evaluated in a string-comparison context (e.g. cross-platform tests).
117pub fn is_ephemeral(path: &Path) -> bool {
118    // Normalize separators first so that Windows-style backslash paths
119    // (`C:\Users\u\Temp\app`) are handled uniformly on all platforms.
120    let full = normalize_separators(&path.to_string_lossy());
121
122    // Strip the last component (the file name) to get the parent portion.
123    // We work on the normalized string rather than calling `path.parent()`
124    // because on Unix, `Path::new(r"C:\foo\bar")` is a single component and
125    // its `parent()` returns `Some("")`, preventing any match.
126    let parent = match full.rfind('/') {
127        Some(pos) => &full[..pos],
128        None => return false,
129    };
130    if parent.is_empty() {
131        return false;
132    }
133
134    // .app bundle exclusion: only evaluate the path after .app/
135    let check_target = if let Some(pos) = parent.find(".app/") {
136        &parent[pos + 5..]
137    } else {
138        parent
139    };
140
141    for component in check_target.split('/') {
142        let lower = component.to_ascii_lowercase();
143        for word in lower.split(['-', '_', '.']) {
144            if matches!(word, "cache" | "tmp" | "temp" | "temporary") {
145                return true;
146            }
147        }
148    }
149    false
150}
151
152/// Check if a path is an executable file
153#[cfg(unix)]
154pub fn is_executable(path: &Path) -> bool {
155    use std::os::unix::fs::PermissionsExt;
156    match fs::metadata(path) {
157        Ok(meta) => meta.is_file() && (meta.permissions().mode() & 0o111 != 0),
158        Err(_) => false,
159    }
160}
161
162/// Check if a path is an executable file (Windows: match PATHEXT extensions)
163#[cfg(windows)]
164pub fn is_executable(path: &Path) -> bool {
165    if !path.is_file() {
166        return false;
167    }
168    let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| {
169        ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.PS1".to_string()
170    });
171    match path.extension().and_then(|e| e.to_str()) {
172        Some(ext) => pathext.split(';').any(|pe| {
173            pe.strip_prefix('.')
174                .is_some_and(|pe| pe.eq_ignore_ascii_case(ext))
175        }),
176        None => false,
177    }
178}
179
180/// Check if a path is an executable file (fallback for other platforms)
181#[cfg(not(any(unix, windows)))]
182pub fn is_executable(path: &Path) -> bool {
183    path.is_file()
184}
185
186/// Compare two files for byte-identical content.
187/// Returns true only if both files exist, have the same size, and identical content.
188pub fn files_have_same_content(path_a: &Path, path_b: &Path) -> bool {
189    // 1. Compare file sizes first (fast rejection)
190    let meta_a = match fs::metadata(path_a) {
191        Ok(m) => m,
192        Err(_) => return false,
193    };
194    let meta_b = match fs::metadata(path_b) {
195        Ok(m) => m,
196        Err(_) => return false,
197    };
198    if meta_a.len() != meta_b.len() {
199        return false;
200    }
201
202    // 2. Byte-by-byte streaming comparison
203    let mut file_a = match fs::File::open(path_a) {
204        Ok(f) => f,
205        Err(_) => return false,
206    };
207    let mut file_b = match fs::File::open(path_b) {
208        Ok(f) => f,
209        Err(_) => return false,
210    };
211
212    // 64KB buffer: aligns with modern NVMe I/O and Apple Silicon 16KB pages
213    let mut buf_a = [0u8; 65536];
214    let mut buf_b = [0u8; 65536];
215
216    loop {
217        let n_a = match file_a.read(&mut buf_a) {
218            Ok(n) => n,
219            Err(_) => return false,
220        };
221        let n_b = match file_b.read(&mut buf_b) {
222            Ok(n) => n,
223            Err(_) => return false,
224        };
225        if n_a != n_b || buf_a[..n_a] != buf_b[..n_b] {
226            return false;
227        }
228        if n_a == 0 {
229            return true;
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::path::Path;
238
239    // --- detect_version_manager ---
240
241    #[test]
242    fn test_detect_mise() {
243        let path = Path::new("/home/user/.local/share/mise/installs/node/20/bin/node");
244        let result = detect_version_manager(path).unwrap();
245        assert_eq!(result.name, "mise");
246    }
247
248    #[test]
249    fn test_detect_dot_mise() {
250        let path = Path::new("/home/user/.mise/installs/python/3.12/bin/python3");
251        let result = detect_version_manager(path).unwrap();
252        assert_eq!(result.name, "mise");
253    }
254
255    #[test]
256    fn test_detect_asdf() {
257        let path = Path::new("/home/user/.asdf/installs/ruby/3.2.0/bin/ruby");
258        let result = detect_version_manager(path).unwrap();
259        assert_eq!(result.name, "asdf");
260    }
261
262    #[test]
263    fn test_detect_nix_store() {
264        let path = Path::new("/nix/store/abc123-hello/bin/hello");
265        let result = detect_version_manager(path).unwrap();
266        assert_eq!(result.name, "nix");
267    }
268
269    #[test]
270    fn test_detect_nix_profile() {
271        let path = Path::new("/home/user/.nix-profile/bin/git");
272        let result = detect_version_manager(path).unwrap();
273        assert_eq!(result.name, "nix");
274    }
275
276    #[test]
277    fn test_detect_homebrew_cellar() {
278        let path = Path::new("/opt/homebrew/Cellar/git/2.44.0/bin/git");
279        let result = detect_version_manager(path).unwrap();
280        assert_eq!(result.name, "homebrew");
281    }
282
283    #[test]
284    fn test_detect_homebrew_caskroom() {
285        let path = Path::new("/opt/homebrew/Caskroom/firefox/125.0/Firefox.app");
286        let result = detect_version_manager(path).unwrap();
287        assert_eq!(result.name, "homebrew");
288    }
289
290    #[test]
291    fn test_detect_nvm() {
292        let path = Path::new("/home/user/.nvm/versions/node/v20.0.0/bin/node");
293        let result = detect_version_manager(path).unwrap();
294        assert_eq!(result.name, "nvm");
295    }
296
297    #[test]
298    fn test_detect_fnm() {
299        let path = Path::new("/home/user/.fnm/node-versions/v20.0.0/installation/bin/node");
300        let result = detect_version_manager(path).unwrap();
301        assert_eq!(result.name, "fnm");
302    }
303
304    #[test]
305    fn test_detect_rustup() {
306        let path = Path::new("/home/user/.rustup/toolchains/stable-x86_64/bin/rustc");
307        let result = detect_version_manager(path).unwrap();
308        assert_eq!(result.name, "rustup");
309    }
310
311    #[test]
312    fn test_detect_volta() {
313        let path = Path::new("/home/user/.volta/tools/image/node/20.0.0/bin/node");
314        let result = detect_version_manager(path).unwrap();
315        assert_eq!(result.name, "volta");
316    }
317
318    #[test]
319    fn test_detect_sdkman() {
320        let path = Path::new("/home/user/.sdkman/candidates/java/17.0.1/bin/java");
321        let result = detect_version_manager(path).unwrap();
322        assert_eq!(result.name, "sdkman");
323    }
324
325    #[test]
326    fn test_detect_pyenv() {
327        let path = Path::new("/home/user/.pyenv/versions/3.12.0/bin/python3");
328        let result = detect_version_manager(path).unwrap();
329        assert_eq!(result.name, "pyenv");
330    }
331
332    #[test]
333    fn test_detect_rbenv() {
334        let path = Path::new("/home/user/.rbenv/versions/3.2.0/bin/ruby");
335        let result = detect_version_manager(path).unwrap();
336        assert_eq!(result.name, "rbenv");
337    }
338
339    #[test]
340    fn test_detect_goenv() {
341        let path = Path::new("/home/user/.goenv/versions/1.21.0/bin/go");
342        let result = detect_version_manager(path).unwrap();
343        assert_eq!(result.name, "goenv");
344    }
345
346    #[test]
347    fn test_detect_aqua() {
348        let path = Path::new("/home/user/.local/share/aquaproj-aqua/internal/pkgs/foo/bin/foo");
349        let result = detect_version_manager(path).unwrap();
350        assert_eq!(result.name, "aqua");
351    }
352
353    #[test]
354    fn test_detect_proto() {
355        let path = Path::new("/home/user/.proto/tools/node/20.0.0/bin/node");
356        let result = detect_version_manager(path).unwrap();
357        assert_eq!(result.name, "proto");
358    }
359
360    #[test]
361    fn test_detect_none_for_system_path() {
362        let path = Path::new("/usr/bin/git");
363        assert!(detect_version_manager(path).is_none());
364    }
365
366    #[test]
367    fn test_detect_none_for_local_bin() {
368        let path = Path::new("/usr/local/bin/node");
369        assert!(detect_version_manager(path).is_none());
370    }
371
372    // --- is_shim_path ---
373
374    #[test]
375    fn test_shim_mise() {
376        assert!(is_shim_path(Path::new(
377            "/home/user/.local/share/mise/shims/node"
378        )));
379    }
380
381    #[test]
382    fn test_shim_dot_mise() {
383        assert!(is_shim_path(Path::new("/home/user/.mise/shims/python3")));
384    }
385
386    #[test]
387    fn test_shim_asdf() {
388        assert!(is_shim_path(Path::new("/home/user/.asdf/shims/ruby")));
389    }
390
391    #[test]
392    fn test_shim_pyenv() {
393        assert!(is_shim_path(Path::new("/home/user/.pyenv/shims/python3")));
394    }
395
396    #[test]
397    fn test_shim_rbenv() {
398        assert!(is_shim_path(Path::new("/home/user/.rbenv/shims/ruby")));
399    }
400
401    #[test]
402    fn test_shim_goenv() {
403        assert!(is_shim_path(Path::new("/home/user/.goenv/shims/go")));
404    }
405
406    #[test]
407    fn test_shim_proto() {
408        assert!(is_shim_path(Path::new("/home/user/.proto/shims/node")));
409    }
410
411    #[test]
412    fn test_not_shim_system_bin() {
413        assert!(!is_shim_path(Path::new("/usr/bin/git")));
414    }
415
416    #[test]
417    fn test_not_shim_installs() {
418        assert!(!is_shim_path(Path::new(
419            "/home/user/.mise/installs/node/20/bin/node"
420        )));
421    }
422
423    // --- is_shim_by_name ---
424
425    #[test]
426    fn test_shim_by_name_different_binary() {
427        // git -> jj-worktree is a shim (names completely different)
428        assert!(is_shim_by_name("git", "jj-worktree"));
429    }
430
431    #[test]
432    fn test_shim_by_name_version_suffix_not_shim() {
433        // python3 -> python3.12 is NOT a shim (version suffix)
434        assert!(!is_shim_by_name("python3", "python3.12"));
435    }
436
437    #[test]
438    fn test_shim_by_name_version_dash_suffix_not_shim() {
439        // foo -> foo-0.2.1 is NOT a shim (version suffix with dash)
440        assert!(!is_shim_by_name("foo", "foo-0.2.1"));
441    }
442
443    #[test]
444    fn test_shim_by_name_exact_match_not_shim() {
445        // same name is not a shim
446        assert!(!is_shim_by_name("node", "node"));
447    }
448
449    // --- is_build_output ---
450
451    #[test]
452    fn test_build_output_target_debug() {
453        assert!(is_build_output(Path::new(
454            "/home/user/project/target/debug/myapp"
455        )));
456    }
457
458    #[test]
459    fn test_build_output_target_release() {
460        assert!(is_build_output(Path::new(
461            "/home/user/project/target/release/myapp"
462        )));
463    }
464
465    #[test]
466    fn test_build_output_swift_build() {
467        assert!(is_build_output(Path::new(
468            "/home/user/project/.build/debug/myapp"
469        )));
470    }
471
472    #[test]
473    fn test_build_output_haskell_dist() {
474        assert!(is_build_output(Path::new(
475            "/home/user/project/dist-newstyle/build/myapp"
476        )));
477    }
478
479    #[test]
480    fn test_build_output_stack() {
481        assert!(is_build_output(Path::new(
482            "/home/user/project/.stack-work/install/bin/myapp"
483        )));
484    }
485
486    #[test]
487    fn test_build_output_elixir_build() {
488        assert!(is_build_output(Path::new(
489            "/home/user/project/_build/dev/lib/myapp"
490        )));
491    }
492
493    #[test]
494    fn test_build_output_zig_out() {
495        assert!(is_build_output(Path::new(
496            "/home/user/project/zig-out/bin/myapp"
497        )));
498    }
499
500    #[test]
501    fn test_build_output_zig_cache() {
502        assert!(is_build_output(Path::new(
503            "/home/user/project/zig-cache/o/myobj"
504        )));
505    }
506
507    #[test]
508    fn test_build_output_cmake() {
509        assert!(is_build_output(Path::new(
510            "/home/user/project/cmake-build-debug/bin/myapp"
511        )));
512    }
513
514    #[test]
515    fn test_build_output_dotnet_debug() {
516        assert!(is_build_output(Path::new(
517            "/home/user/project/bin/Debug/net8.0/myapp"
518        )));
519    }
520
521    #[test]
522    fn test_build_output_dotnet_release() {
523        assert!(is_build_output(Path::new(
524            "/home/user/project/bin/Release/net8.0/myapp"
525        )));
526    }
527
528    #[test]
529    fn test_build_output_dub() {
530        assert!(is_build_output(Path::new(
531            "/home/user/project/.dub/build/myapp"
532        )));
533    }
534
535    #[test]
536    fn test_build_output_nim() {
537        assert!(is_build_output(Path::new(
538            "/home/user/project/nimcache/myapp"
539        )));
540    }
541
542    #[test]
543    fn test_build_output_xcode_derived_data() {
544        assert!(is_build_output(Path::new(
545            "/Users/user/Library/Developer/Xcode/DerivedData/MyApp-abc/Build/Products/Debug/myapp"
546        )));
547    }
548
549    #[test]
550    fn test_build_output_xcode_build_products() {
551        assert!(is_build_output(Path::new(
552            "/Users/user/project/Build/Products/Release/myapp"
553        )));
554    }
555
556    #[test]
557    fn test_build_output_moonbit_wasm_gc() {
558        assert!(is_build_output(Path::new(
559            "/home/user/project/target/wasm-gc/release/build/main/main.wasm"
560        )));
561    }
562
563    #[test]
564    fn test_build_output_moonbit_wasm() {
565        assert!(is_build_output(Path::new(
566            "/home/user/project/target/wasm/release/build/main/main.wasm"
567        )));
568    }
569
570    #[test]
571    fn test_build_output_moonbit_js() {
572        assert!(is_build_output(Path::new(
573            "/home/user/project/target/js/release/build/main/main.js"
574        )));
575    }
576
577    #[test]
578    fn test_not_build_output_system_bin() {
579        assert!(!is_build_output(Path::new("/usr/bin/git")));
580    }
581
582    #[test]
583    fn test_not_build_output_local_bin() {
584        assert!(!is_build_output(Path::new("/usr/local/bin/node")));
585    }
586
587    // --- is_ephemeral ---
588
589    #[test]
590    fn test_ephemeral_tmp() {
591        assert!(is_ephemeral(Path::new("/tmp/foo/bar")));
592    }
593
594    #[test]
595    fn test_ephemeral_cache_dir() {
596        assert!(is_ephemeral(Path::new("/home/user/.cache/x/bin/y")));
597    }
598
599    #[test]
600    fn test_ephemeral_temp_upper() {
601        // case insensitive
602        assert!(is_ephemeral(Path::new("/Users/x/TEMP/y")));
603    }
604
605    #[test]
606    fn test_not_ephemeral_cache_warden() {
607        // "cache-warden" as a binary name should not trigger (we check parent only)
608        // But even if parent contained "cache", word boundary prevents "cache-warden" dir from matching
609        // Actually /usr/bin is the parent here, so no match
610        assert!(!is_ephemeral(Path::new("/usr/bin/cache-warden")));
611    }
612
613    #[test]
614    fn test_not_ephemeral_app_bundle_cache_name() {
615        // "Cache Warden.app" in path before .app/ should be excluded
616        assert!(!is_ephemeral(Path::new(
617            "/Applications/Cache Warden.app/Contents/MacOS/bin/cache-warden"
618        )));
619    }
620
621    #[test]
622    fn test_ephemeral_temporary_dir() {
623        assert!(is_ephemeral(Path::new("/var/temporary/data/bin/foo")));
624    }
625
626    #[test]
627    fn test_not_ephemeral_normal_path() {
628        assert!(!is_ephemeral(Path::new("/usr/local/bin/node")));
629    }
630
631    #[test]
632    fn test_ephemeral_compound_component() {
633        // "my-cache-dir" contains "cache" as a word separated by hyphens
634        assert!(is_ephemeral(Path::new("/opt/my-cache-dir/bin/foo")));
635    }
636
637    #[test]
638    fn test_not_ephemeral_no_parent() {
639        assert!(!is_ephemeral(Path::new("binary")));
640    }
641
642    // --- files_have_same_content ---
643
644    #[test]
645    fn test_same_content() {
646        let dir = tempfile::tempdir().unwrap();
647        let f1 = dir.path().join("a");
648        let f2 = dir.path().join("b");
649        std::fs::write(&f1, "hello world").unwrap();
650        std::fs::write(&f2, "hello world").unwrap();
651        assert!(files_have_same_content(&f1, &f2));
652    }
653
654    #[test]
655    fn test_different_content() {
656        let dir = tempfile::tempdir().unwrap();
657        let f1 = dir.path().join("a");
658        let f2 = dir.path().join("b");
659        std::fs::write(&f1, "hello").unwrap();
660        std::fs::write(&f2, "world").unwrap();
661        assert!(!files_have_same_content(&f1, &f2));
662    }
663
664    #[test]
665    fn test_different_size() {
666        let dir = tempfile::tempdir().unwrap();
667        let f1 = dir.path().join("a");
668        let f2 = dir.path().join("b");
669        std::fs::write(&f1, "short").unwrap();
670        std::fs::write(&f2, "much longer content").unwrap();
671        assert!(!files_have_same_content(&f1, &f2));
672    }
673
674    #[test]
675    fn test_empty_files() {
676        let dir = tempfile::tempdir().unwrap();
677        let f1 = dir.path().join("a");
678        let f2 = dir.path().join("b");
679        std::fs::write(&f1, "").unwrap();
680        std::fs::write(&f2, "").unwrap();
681        assert!(files_have_same_content(&f1, &f2));
682    }
683
684    #[test]
685    fn test_nonexistent_file() {
686        let dir = tempfile::tempdir().unwrap();
687        let f1 = dir.path().join("a");
688        std::fs::write(&f1, "hello").unwrap();
689        assert!(!files_have_same_content(
690            &f1,
691            Path::new("/nonexistent/file")
692        ));
693        assert!(!files_have_same_content(
694            Path::new("/nonexistent/file"),
695            &f1
696        ));
697    }
698
699    // --- is_executable ---
700
701    #[cfg(unix)]
702    #[test]
703    fn test_is_executable_true() {
704        use std::os::unix::fs::PermissionsExt;
705        let tmpdir = tempfile::tempdir().unwrap();
706        let path = tmpdir.path().join("exec_file");
707        fs::write(&path, "content").unwrap();
708        fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap();
709        assert!(is_executable(&path));
710    }
711
712    #[cfg(unix)]
713    #[test]
714    fn test_is_executable_false_no_exec_bit() {
715        use std::os::unix::fs::PermissionsExt;
716        let tmpdir = tempfile::tempdir().unwrap();
717        let path = tmpdir.path().join("no_exec");
718        fs::write(&path, "content").unwrap();
719        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
720        assert!(!is_executable(&path));
721    }
722
723    #[test]
724    fn test_is_executable_false_directory() {
725        let tmpdir = tempfile::tempdir().unwrap();
726        assert!(!is_executable(tmpdir.path()));
727    }
728
729    #[test]
730    fn test_is_executable_false_nonexistent() {
731        assert!(!is_executable(Path::new("/nonexistent/path")));
732    }
733
734    // --- Windows backslash path tests (run on all platforms via normalize_separators) ---
735
736    // is_build_output: Windows target\release path
737    #[test]
738    fn test_build_output_windows_target_release() {
739        assert!(is_build_output(Path::new(
740            r"C:\Users\u\project\target\release\app.exe"
741        )));
742    }
743
744    // is_build_output: Windows target\debug path
745    #[test]
746    fn test_build_output_windows_target_debug() {
747        assert!(is_build_output(Path::new(
748            r"C:\Users\u\project\target\debug\app.exe"
749        )));
750    }
751
752    // is_ephemeral: Windows temp directory
753    #[test]
754    fn test_ephemeral_windows_temp() {
755        assert!(is_ephemeral(Path::new(
756            r"C:\Users\u\AppData\Local\Temp\app"
757        )));
758    }
759
760    // is_ephemeral: Windows cache directory
761    #[test]
762    fn test_ephemeral_windows_cache() {
763        assert!(is_ephemeral(Path::new(
764            r"C:\Users\u\AppData\Local\cache\app"
765        )));
766    }
767
768    // detect_version_manager: Windows scoop installs path
769    #[test]
770    fn test_detect_windows_scoop() {
771        let path = Path::new(r"C:\Users\u\scoop\apps\node\22.0.0\node.exe");
772        let result = detect_version_manager(path).unwrap();
773        assert_eq!(result.name, "scoop");
774    }
775
776    // detect_version_manager: Windows chocolatey path
777    #[test]
778    fn test_detect_windows_chocolatey() {
779        let path = Path::new(r"C:\ProgramData\chocolatey\lib\node\tools\node.exe");
780        let result = detect_version_manager(path).unwrap();
781        assert_eq!(result.name, "chocolatey");
782    }
783
784    // is_shim_path: Windows scoop shims path
785    #[test]
786    fn test_shim_windows_scoop() {
787        assert!(is_shim_path(Path::new(r"C:\Users\u\scoop\shims\foo.exe")));
788    }
789
790    // is_shim_path: Windows chocolatey bin path
791    #[test]
792    fn test_shim_windows_chocolatey_bin() {
793        assert!(is_shim_path(Path::new(
794            r"C:\ProgramData\chocolatey\bin\foo.exe"
795        )));
796    }
797
798    // is_build_output: not a build output for normal Windows system binary
799    #[test]
800    fn test_not_build_output_windows_system32() {
801        assert!(!is_build_output(Path::new(r"C:\Windows\System32\cmd.exe")));
802    }
803
804    // is_ephemeral: not ephemeral for normal Windows system binary
805    #[test]
806    fn test_not_ephemeral_windows_system32() {
807        assert!(!is_ephemeral(Path::new(r"C:\Windows\System32\cmd.exe")));
808    }
809}