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
64pub 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
77pub 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
83pub fn is_shim_by_name(candidate_name: &str, symlink_target_name: &str) -> bool {
87 !symlink_target_name.starts_with(candidate_name)
88}
89
90pub 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
98pub 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 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
127pub 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
136pub fn files_have_same_content(path_a: &Path, path_b: &Path) -> bool {
139 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 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 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 #[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 #[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 #[test]
376 fn test_shim_by_name_different_binary() {
377 assert!(is_shim_by_name("git", "jj-worktree"));
379 }
380
381 #[test]
382 fn test_shim_by_name_version_suffix_not_shim() {
383 assert!(!is_shim_by_name("python3", "python3.12"));
385 }
386
387 #[test]
388 fn test_shim_by_name_version_dash_suffix_not_shim() {
389 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 assert!(!is_shim_by_name("node", "node"));
397 }
398
399 #[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 #[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 assert!(is_ephemeral(Path::new("/Users/x/TEMP/y")));
553 }
554
555 #[test]
556 fn test_not_ephemeral_cache_warden() {
557 assert!(!is_ephemeral(Path::new("/usr/bin/cache-warden")));
561 }
562
563 #[test]
564 fn test_not_ephemeral_app_bundle_cache_name() {
565 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 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 #[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 #[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}