Skip to main content

stable_which/
path_analysis.rs

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