1use std::fs;
2use std::io::Read;
3use std::path::Path;
4
5fn 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
74pub 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
87pub 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
93pub fn is_shim_by_name(candidate_name: &str, symlink_target_name: &str) -> bool {
97 !symlink_target_name.starts_with(candidate_name)
98}
99
100pub 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
108pub fn is_ephemeral(path: &Path) -> bool {
118 let full = normalize_separators(&path.to_string_lossy());
121
122 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 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#[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#[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#[cfg(not(any(unix, windows)))]
182pub fn is_executable(path: &Path) -> bool {
183 path.is_file()
184}
185
186pub fn files_have_same_content(path_a: &Path, path_b: &Path) -> bool {
189 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 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 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 #[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 #[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 #[test]
426 fn test_shim_by_name_different_binary() {
427 assert!(is_shim_by_name("git", "jj-worktree"));
429 }
430
431 #[test]
432 fn test_shim_by_name_version_suffix_not_shim() {
433 assert!(!is_shim_by_name("python3", "python3.12"));
435 }
436
437 #[test]
438 fn test_shim_by_name_version_dash_suffix_not_shim() {
439 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 assert!(!is_shim_by_name("node", "node"));
447 }
448
449 #[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 #[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 assert!(is_ephemeral(Path::new("/Users/x/TEMP/y")));
603 }
604
605 #[test]
606 fn test_not_ephemeral_cache_warden() {
607 assert!(!is_ephemeral(Path::new("/usr/bin/cache-warden")));
611 }
612
613 #[test]
614 fn test_not_ephemeral_app_bundle_cache_name() {
615 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
806 fn test_not_ephemeral_windows_system32() {
807 assert!(!is_ephemeral(Path::new(r"C:\Windows\System32\cmd.exe")));
808 }
809}