1pub fn hook_binary_override() -> Option<String> {
12 std::env::var("LEAN_CTX_HOOK_BINARY")
13 .ok()
14 .or_else(|| crate::core::config::Config::load().hook_binary.clone())
15 .map(|s| s.trim().to_string())
16 .filter(|s| !s.is_empty())
17}
18
19pub fn is_lean_ctx_hook_binary(binary: &str) -> bool {
21 let binary = binary.trim().trim_matches(['\'', '"']);
22 let name = binary.rsplit(['/', '\\']).next().unwrap_or(binary);
23 matches!(
24 name,
25 "lean-ctx" | "lean-ctx.exe" | "lean-ctx.cmd" | "lean-ctx.bat"
26 ) || hook_binary_override()
27 .is_some_and(|configured| configured.trim_matches(['\'', '"']) == binary)
28}
29
30pub fn is_lean_ctx_hook_command(command: &str, operation: &str) -> bool {
34 let Some((binary, action)) = command.trim().rsplit_once(" hook ") else {
35 return false;
36 };
37 let binary = binary.trim();
38 let quoted = binary.len() >= 2
39 && ((binary.starts_with('"') && binary.ends_with('"'))
40 || (binary.starts_with('\'') && binary.ends_with('\'')));
41 let binary_body = if quoted {
42 &binary[1..binary.len() - 1]
43 } else {
44 binary
45 };
46 action == operation
47 && !binary_body.contains(['\'', '"', '`'])
48 && (quoted || !binary_body.chars().any(char::is_whitespace))
49 && is_lean_ctx_hook_binary(binary)
50}
51
52pub fn resolve_portable_binary() -> String {
53 let current = std::env::current_exe()
54 .ok()
55 .map(|p| p.to_string_lossy().into_owned());
56
57 let which_raw = crate::core::pathutil::find_all_on_path("lean-ctx")
60 .into_iter()
61 .map(|p| p.to_string_lossy().into_owned())
62 .find(|s| !s.is_empty() && !is_transient_build_binary(s));
63
64 choose_binary_path(current.as_deref(), which_raw.as_deref())
65}
66
67const TRANSIENT_CARGO_DIRS: &[&str] = &["deps", "build", "examples", "incremental"];
70
71pub fn is_transient_build_binary(path: &str) -> bool {
83 let comps: Vec<&str> = path.split(['/', '\\']).collect();
84 (1..comps.len().saturating_sub(1)).any(|i| {
85 if !TRANSIENT_CARGO_DIRS.contains(&comps[i]) {
86 return false;
87 }
88 let profile = comps[i - 1];
89 let named_profile = matches!(profile, "debug" | "release")
90 && ((i >= 2 && comps[i - 2].ends_with("target"))
91 || (i >= 3 && comps[i - 3].ends_with("target")));
92 named_profile || is_cargo_profile_dir(std::path::Path::new(&comps[..i].join("/")))
93 })
94}
95
96pub fn refuses_transient_binary_for_home(binary: &str, home: &std::path::Path) -> bool {
105 let real_home = crate::core::config::shell_policy::real_user_home();
106 let refused = transient_binary_for_real_home(binary, home, real_home.as_deref());
107 if refused {
108 tracing::warn!(
109 "not writing transient build binary {binary} into the hook configuration of {}; \
110 install lean-ctx (or put it on PATH) and run `lean-ctx setup`",
111 home.display()
112 );
113 }
114 refused
115}
116
117fn transient_binary_for_real_home(
118 binary: &str,
119 home: &std::path::Path,
120 real_home: Option<&std::path::Path>,
121) -> bool {
122 if !is_transient_build_binary(binary.trim().trim_matches(['\'', '"'])) {
123 return false;
124 }
125 let Some(real_home) = real_home else {
127 return false;
128 };
129 let canonical = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
130 canonical(home) == canonical(real_home)
131}
132
133const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
135 &["lean-ctx.exe", "lean-ctx.cmd"]
136} else {
137 &["lean-ctx"]
138};
139
140pub fn path_dirs() -> Vec<std::path::PathBuf> {
142 std::env::var_os("PATH")
143 .map(|p| {
144 std::env::split_paths(&p)
145 .filter(|d| !d.as_os_str().is_empty())
146 .collect()
147 })
148 .unwrap_or_default()
149}
150
151pub fn dir_on_path(dir: &std::path::Path, path_dirs: &[std::path::PathBuf]) -> bool {
153 let canonical = std::fs::canonicalize(dir).ok();
154 path_dirs
155 .iter()
156 .any(|d| d == dir || (canonical.is_some() && std::fs::canonicalize(d).ok() == canonical))
157}
158
159pub fn launchers_on_path(path_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
162 path_dirs
163 .iter()
164 .filter_map(|d| {
165 LAUNCHER_NAMES
166 .iter()
167 .map(|name| d.join(name))
168 .find(|p| p.is_file())
169 })
170 .collect()
171}
172
173pub fn stable_shell_binary(binary: &str) -> String {
184 stable_shell_binary_in(binary, &path_dirs())
185}
186
187pub fn resolve_agent_binary() -> String {
196 stable_shell_binary(&resolve_portable_binary())
197}
198
199fn stable_shell_binary_in(binary: &str, path_dirs: &[std::path::PathBuf]) -> String {
200 let path = std::path::Path::new(binary);
201 if !path.is_absolute() || path.parent().is_some_and(|d| dir_on_path(d, path_dirs)) {
202 return binary.to_string();
203 }
204 let Ok(canonical) = std::fs::canonicalize(path) else {
205 return binary.to_string();
206 };
207 launchers_on_path(path_dirs)
208 .iter()
209 .find(|launcher| launcher_runs(launcher, &canonical))
210 .map_or_else(
211 || binary.to_string(),
212 |p| sanitize_exe_path(&p.to_string_lossy()),
213 )
214}
215
216fn launcher_runs(launcher: &std::path::Path, target: &std::path::Path) -> bool {
219 std::fs::canonicalize(launcher).is_ok_and(|l| l == target)
220 || scoop_shim_target(launcher)
221 .is_some_and(|t| std::fs::canonicalize(t).is_ok_and(|t| t == target))
222 || version_manager_shim_owns(launcher, target)
223}
224
225const MAX_SHIM_BYTES: u64 = 4096;
227
228fn scoop_shim_target(launcher: &std::path::Path) -> Option<std::path::PathBuf> {
233 use std::io::Read;
234 let mut text = String::new();
235 std::fs::File::open(launcher.with_extension("shim"))
236 .ok()?
237 .take(MAX_SHIM_BYTES + 1)
238 .read_to_string(&mut text)
239 .ok()?;
240 if text.len() as u64 > MAX_SHIM_BYTES {
241 return None;
242 }
243 text.lines()
244 .find_map(|line| {
245 let (key, value) = line.split_once('=')?;
246 (key.trim() == "path").then(|| std::path::PathBuf::from(value.trim().trim_matches('"')))
247 })
248 .filter(|p| p.is_absolute())
249}
250
251fn version_manager_shim_owns(launcher: &std::path::Path, target: &std::path::Path) -> bool {
256 let Some(shims) = launcher.parent() else {
257 return false;
258 };
259 if shims.file_name() != Some(std::ffi::OsStr::new("shims")) {
260 return false;
261 }
262 shims.parent().is_some_and(|root| {
263 std::fs::canonicalize(root.join("installs"))
264 .is_ok_and(|installs| target.starts_with(installs))
265 })
266}
267
268fn is_cargo_profile_dir(dir: &std::path::Path) -> bool {
273 dir.join(".cargo-lock").is_file()
274 || (dir.join(".fingerprint").is_dir() && dir.join("deps").is_dir())
275}
276
277pub fn in_cargo_output_dir(path: &std::path::Path) -> bool {
289 let Some(dir) = path.parent() else {
290 return false;
291 };
292 if is_cargo_profile_dir(dir) {
293 return true;
294 }
295 let name = dir.file_name().and_then(|name| name.to_str());
296 if matches!(name, Some("deps" | "examples")) {
297 return dir.parent().is_some_and(is_cargo_profile_dir);
298 }
299 let unit = dir.parent();
301 let build = unit
302 .and_then(std::path::Path::parent)
303 .and_then(std::path::Path::parent);
304 name == Some("out")
305 && unit.is_some_and(|u| u.join("fingerprint").is_dir())
306 && build.is_some_and(|b| b.file_name().is_some_and(|n| n == "build"))
307 && build
308 .and_then(std::path::Path::parent)
309 .is_some_and(is_cargo_profile_dir)
310}
311
312fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
334 let is_build_artifact = |p: &str| {
339 p.split(['/', '\\'])
340 .collect::<Vec<_>>()
341 .windows(2)
342 .any(|w| w[0].ends_with("target") && matches!(w[1], "debug" | "release"))
343 || in_cargo_output_dir(std::path::Path::new(p))
344 };
345
346 if let Some(exe) = current_exe
348 && std::path::Path::new(exe).is_absolute()
349 && !is_build_artifact(exe)
350 {
351 return sanitize_exe_path(exe);
352 }
353
354 if let Some(raw) = which_raw {
357 let path = pick_best_binary_line(raw);
358 if std::path::Path::new(&path).is_absolute() && !is_transient_build_binary(&path) {
359 return sanitize_exe_path(&path);
360 }
361 }
362
363 if let Some(exe) = current_exe
365 && std::path::Path::new(exe).is_absolute()
366 {
367 return sanitize_exe_path(exe);
368 }
369
370 "lean-ctx".to_string()
372}
373
374fn pick_best_binary_line(raw: &str) -> String {
378 let lines: Vec<&str> = raw
379 .lines()
380 .map(str::trim)
381 .filter(|l| !l.is_empty())
382 .collect();
383 if lines.len() <= 1 {
384 return lines.first().unwrap_or(&"lean-ctx").to_string();
385 }
386 if cfg!(windows)
387 && let Some(cmd) = lines.iter().find(|l| {
388 std::path::Path::new(*l).extension().is_some_and(|ext| {
389 ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
390 })
391 })
392 {
393 return cmd.to_string();
394 }
395 lines[0].to_string()
396}
397
398fn sanitize_exe_path(path: &str) -> String {
399 let cleaned = path.trim_end_matches(" (deleted)");
400 if cfg!(windows) {
401 super::pathutil::normalize_tool_path(cleaned)
402 } else {
403 cleaned.to_string()
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn single_line_returns_as_is() {
413 assert_eq!(
414 pick_best_binary_line("/usr/bin/lean-ctx"),
415 "/usr/bin/lean-ctx"
416 );
417 }
418
419 #[test]
420 fn multiline_returns_first_line() {
421 let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
422 let result = pick_best_binary_line(raw);
423 assert_eq!(result, "/usr/bin/lean-ctx");
424 }
425
426 #[test]
427 fn empty_returns_fallback() {
428 assert_eq!(pick_best_binary_line(""), "lean-ctx");
429 }
430
431 #[test]
432 fn sanitize_removes_deleted_suffix() {
433 assert_eq!(
434 sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
435 "/usr/bin/lean-ctx"
436 );
437 }
438
439 #[test]
440 fn whitespace_lines_are_filtered() {
441 let raw = " /usr/bin/lean-ctx \n \n /usr/local/bin/lean-ctx ";
442 assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
443 }
444
445 #[cfg(windows)]
446 #[test]
447 fn sanitize_normalizes_msys_path_on_windows() {
448 assert_eq!(
449 sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
450 "C:/Users/ABC/.local/bin/lean-ctx"
451 );
452 }
453
454 #[cfg(windows)]
455 #[test]
456 fn sanitize_keeps_native_windows_path() {
457 assert_eq!(
458 sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
459 "C:/Users/ABC/lean-ctx.exe"
460 );
461 }
462
463 #[cfg(not(windows))]
464 #[test]
465 fn sanitize_unix_path_unchanged() {
466 assert_eq!(
467 sanitize_exe_path("/usr/local/bin/lean-ctx"),
468 "/usr/local/bin/lean-ctx"
469 );
470 }
471
472 #[test]
476 fn hook_binary_override_env_is_verbatim_and_blank_is_none() {
477 let _lock = crate::core::data_dir::test_env_lock();
478 crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", "$HOME/.local/bin/lean-ctx");
479 assert_eq!(
480 hook_binary_override().as_deref(),
481 Some("$HOME/.local/bin/lean-ctx")
482 );
483 crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", " ");
484 assert_eq!(hook_binary_override(), None);
485 crate::test_env::remove_var("LEAN_CTX_HOOK_BINARY");
486 }
487
488 #[test]
489 fn resolve_portable_binary_is_absolute() {
490 let resolved = resolve_portable_binary();
495 assert!(
496 std::path::Path::new(&resolved).is_absolute(),
497 "resolve_portable_binary must return an absolute path, got: {resolved}"
498 );
499 }
500
501 #[test]
502 fn nothing_resolvable_returns_bare_name() {
503 assert_eq!(choose_binary_path(None, None), "lean-ctx");
505 assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
507 }
508
509 #[cfg(not(windows))]
511 mod unix_paths {
512 use super::*;
513
514 #[test]
515 fn current_exe_beats_path_lookup() {
516 let chosen = choose_binary_path(
519 Some("/Users/dev/.local/bin/lean-ctx"),
520 Some("/opt/homebrew/bin/lean-ctx"),
521 );
522 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
523 }
524
525 #[test]
526 fn release_build_artifact_falls_back_to_path() {
527 let chosen = choose_binary_path(
530 Some("/work/lean-ctx/rust/target/release/lean-ctx"),
531 Some("/Users/dev/.local/bin/lean-ctx"),
532 );
533 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
534 }
535
536 #[test]
537 fn debug_build_artifact_falls_back_to_path() {
538 let chosen = choose_binary_path(
539 Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
540 Some("/usr/local/bin/lean-ctx"),
541 );
542 assert_eq!(chosen, "/usr/local/bin/lean-ctx");
543 }
544
545 #[test]
546 fn relocated_target_dir_falls_back_to_path() {
547 let chosen = choose_binary_path(
550 Some("/Users/dev/.cargo/shared-target/release/lean-ctx"),
551 Some("/Users/dev/.local/bin/lean-ctx"),
552 );
553 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
554 let chosen = choose_binary_path(
556 Some("/opt/lean-ctx/release/lean-ctx"),
557 Some("/Users/dev/.local/bin/lean-ctx"),
558 );
559 assert_eq!(chosen, "/opt/lean-ctx/release/lean-ctx");
560 }
561
562 #[test]
563 fn build_artifact_without_path_keeps_absolute_current_exe() {
564 let chosen =
567 choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
568 assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
569 }
570
571 #[test]
572 fn relative_current_exe_falls_back_to_path() {
573 let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
574 assert_eq!(chosen, "/usr/bin/lean-ctx");
575 }
576
577 #[test]
580 fn transient_build_binaries_are_recognised() {
581 for transient in [
582 "/work/lean-ctx/rust/target/debug/deps/lean_ctx_server-1a2b3c4d",
583 "/work/lean-ctx/rust/target/release/deps/lean_ctx-abc123",
584 "/work/rust/target/debug/examples/demo",
585 "/work/rust/target/debug/build/lean-ctx-0f/build-script-build",
586 "/work/rust/target/debug/incremental/x/lean-ctx",
587 "/work/rust/target/x86_64-unknown-linux-gnu/debug/deps/lean_ctx-1",
588 "/Users/dev/.cargo/shared-target/debug/deps/lean_ctx-1",
589 r"C:\work\rust\target\debug\deps\lean_ctx-1.exe",
590 ] {
591 assert!(is_transient_build_binary(transient), "{transient}");
592 }
593 for stable in [
594 "/work/lean-ctx/rust/target/release/lean-ctx",
595 "/work/lean-ctx/rust/target/debug/lean-ctx",
596 "/Users/dev/.local/bin/lean-ctx",
597 "/opt/build/deps/lean-ctx",
598 "/usr/local/bin/lean-ctx",
599 "lean-ctx",
600 ] {
601 assert!(!is_transient_build_binary(stable), "{stable}");
602 }
603 }
604
605 #[test]
607 fn transient_binary_in_unnamed_target_dir_is_recognised_on_disk() {
608 let tmp = tempfile::tempdir().unwrap();
609 let profile = tmp.path().join("target-agent-shared/debug");
610 std::fs::create_dir_all(profile.join("deps")).unwrap();
611 std::fs::write(profile.join(".cargo-lock"), "").unwrap();
612 let test_bin = profile.join("deps/lean_ctx_server-1a2b");
613 assert!(is_transient_build_binary(&test_bin.to_string_lossy()));
614 assert!(!is_transient_build_binary(
615 &profile.join("lean-ctx").to_string_lossy()
616 ));
617 }
618
619 #[test]
623 fn a_unit_out_dir_of_cargo_1_100_is_a_cargo_output_dir() {
624 let tmp = tempfile::tempdir().unwrap();
625 let profile = tmp.path().join("t/x86_64-pc-windows-gnu/debug");
626 let unit = profile.join("build/lean-ctx/400bfa2068bac60c");
627 std::fs::create_dir_all(unit.join("out")).unwrap();
628 std::fs::create_dir_all(unit.join("fingerprint")).unwrap();
629 std::fs::write(profile.join(".cargo-lock"), "").unwrap();
630 let test_bin = unit.join("out/lean_ctx-400bfa2068bac60c");
631 assert!(in_cargo_output_dir(&test_bin));
632 assert!(is_transient_build_binary(&test_bin.to_string_lossy()));
633
634 std::fs::remove_dir_all(unit.join("fingerprint")).unwrap();
635 assert!(!in_cargo_output_dir(&test_bin));
636 let stray = tmp.path().join("opt/build/pkg/h");
637 std::fs::create_dir_all(stray.join("out")).unwrap();
638 std::fs::create_dir_all(stray.join("fingerprint")).unwrap();
639 assert!(!in_cargo_output_dir(&stray.join("out/lean-ctx")));
640 }
641
642 #[test]
645 fn transient_path_entry_is_not_chosen() {
646 let chosen = choose_binary_path(
647 Some("/work/rust/target/debug/deps/lean_ctx_server-1a2b"),
648 Some("/work/rust/target/debug/deps/lean-ctx"),
649 );
650 assert_eq!(chosen, "/work/rust/target/debug/deps/lean_ctx_server-1a2b");
651 }
652
653 #[test]
656 fn transient_binary_refused_only_for_real_home() {
657 let tmp = tempfile::tempdir().unwrap();
658 let real = tmp.path().join("real");
659 let other = tmp.path().join("other");
660 std::fs::create_dir_all(&real).unwrap();
661 std::fs::create_dir_all(&other).unwrap();
662 let test_bin = "/work/rust/target/debug/deps/lean_ctx_server-1a2b3c4d";
663 assert!(transient_binary_for_real_home(test_bin, &real, Some(&real)));
664 assert!(transient_binary_for_real_home(
665 &format!("\"{test_bin}\""),
666 &real,
667 Some(&real)
668 ));
669 assert!(!transient_binary_for_real_home(
670 test_bin,
671 &other,
672 Some(&real)
673 ));
674 assert!(!transient_binary_for_real_home(test_bin, &real, None));
675 assert!(!transient_binary_for_real_home(
676 "/work/rust/target/release/lean-ctx",
677 &real,
678 Some(&real)
679 ));
680 assert!(!transient_binary_for_real_home(
681 "/Users/dev/.local/bin/lean-ctx",
682 &real,
683 Some(&real)
684 ));
685 }
686
687 #[test]
688 fn path_lookup_multiline_picks_first() {
689 let chosen = choose_binary_path(
690 None,
691 Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
692 );
693 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
694 }
695 }
696
697 #[cfg(unix)]
700 mod stable_shell_binary {
701 use super::super::*;
702 use std::path::{Path, PathBuf};
703
704 fn launcher(dir: &Path) -> PathBuf {
705 std::fs::create_dir_all(dir).unwrap();
706 let path = dir.join("lean-ctx");
707 std::fs::write(&path, "#!/bin/sh\n").unwrap();
708 path
709 }
710
711 #[test]
712 fn binary_in_a_path_dir_is_kept() {
713 let tmp = tempfile::tempdir().unwrap();
714 let bin = launcher(&tmp.path().join("bin"));
715 let other = launcher(&tmp.path().join("other"));
716 let dirs: Vec<PathBuf> =
717 vec![other.parent().unwrap().into(), bin.parent().unwrap().into()];
718 let bin = bin.to_string_lossy();
719 assert_eq!(stable_shell_binary_in(&bin, &dirs), bin);
720 }
721
722 #[test]
723 fn versioned_install_dir_resolves_to_the_path_launcher() {
724 let tmp = tempfile::tempdir().unwrap();
725 let cellar = launcher(&tmp.path().join("Cellar/lean-ctx/3.10.2/bin"));
726 let unrelated = launcher(&tmp.path().join("other"));
727 let shims = tmp.path().join("shims/lean-ctx");
728 std::fs::create_dir_all(shims.parent().unwrap()).unwrap();
729 std::os::unix::fs::symlink(&cellar, &shims).unwrap();
730 let dirs = vec![
731 unrelated.parent().unwrap().into(),
732 shims.parent().unwrap().into(),
733 ];
734 assert_eq!(
735 stable_shell_binary_in(&cellar.to_string_lossy(), &dirs),
736 shims.to_string_lossy()
737 );
738 }
739
740 #[test]
741 fn symlinked_path_dir_counts_as_on_path() {
742 let tmp = tempfile::tempdir().unwrap();
743 let bin = launcher(&tmp.path().join("real"));
744 let link = tmp.path().join("link");
745 std::os::unix::fs::symlink(bin.parent().unwrap(), &link).unwrap();
746 let bin = bin.to_string_lossy();
747 assert_eq!(stable_shell_binary_in(&bin, &[link]), bin);
748 }
749
750 #[test]
751 fn no_launcher_on_path_keeps_the_binary() {
752 let tmp = tempfile::tempdir().unwrap();
753 let bin = launcher(&tmp.path().join("apps/3.10.2"));
754 let bin = bin.to_string_lossy();
755 assert_eq!(stable_shell_binary_in(&bin, &[tmp.path().join("x")]), bin);
756 assert_eq!(stable_shell_binary_in("lean-ctx", &[]), "lean-ctx");
757 }
758
759 #[test]
760 fn unrelated_public_launcher_keeps_the_selected_binary() {
761 let tmp = tempfile::tempdir().unwrap();
762 let selected = launcher(&tmp.path().join("private-install"));
763 let public = launcher(&tmp.path().join("public-bin"));
764 let selected = selected.to_string_lossy();
765 assert_eq!(
766 stable_shell_binary_in(&selected, &[public.parent().unwrap().into()]),
767 selected
768 );
769 }
770
771 fn shim_file(launcher: &Path, target: &Path) {
772 let body = format!("path = \"{}\"\nargs = \n", target.display());
773 std::fs::write(launcher.with_extension("shim"), body).unwrap();
774 }
775
776 #[test]
777 fn shim_file_naming_the_binary_verifies_the_launcher() {
778 let tmp = tempfile::tempdir().unwrap();
779 let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
780 let shim = launcher(&tmp.path().join("shims"));
781 shim_file(&shim, &exe);
782 assert_eq!(
783 stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
784 shim.to_string_lossy()
785 );
786 }
787
788 #[test]
789 fn shim_file_naming_another_binary_is_ignored() {
790 let tmp = tempfile::tempdir().unwrap();
791 let exe = launcher(&tmp.path().join("private-install"));
792 let public = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
793 let shim = launcher(&tmp.path().join("shims"));
794 shim_file(&shim, &public);
795 let exe = exe.to_string_lossy();
796 assert_eq!(
797 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
798 exe
799 );
800 }
801
802 #[test]
803 fn oversized_shim_file_is_ignored() {
804 let tmp = tempfile::tempdir().unwrap();
805 let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
806 let shim = launcher(&tmp.path().join("shims"));
807 let padding = " ".repeat(MAX_SHIM_BYTES as usize);
808 let body = format!("{padding}\npath = \"{}\"\n", exe.display());
809 std::fs::write(shim.with_extension("shim"), body).unwrap();
810 let exe = exe.to_string_lossy();
811 assert_eq!(
812 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
813 exe
814 );
815 }
816
817 #[test]
818 fn version_manager_shim_owning_the_install_tree_is_used() {
819 let tmp = tempfile::tempdir().unwrap();
820 let data = tmp.path().join("mise");
821 let exe = launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
822 let shim = launcher(&data.join("shims"));
823 assert_eq!(
824 stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
825 shim.to_string_lossy()
826 );
827 }
828
829 #[test]
830 fn version_manager_shim_of_another_tree_is_ignored() {
831 let tmp = tempfile::tempdir().unwrap();
832 let exe = launcher(&tmp.path().join("private/installs/lean-ctx/3.10.4/bin"));
833 let data = tmp.path().join("mise");
834 launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
835 let shim = launcher(&data.join("shims"));
836 let exe = exe.to_string_lossy();
837 assert_eq!(
838 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
839 exe
840 );
841 }
842 }
843
844 #[cfg(windows)]
847 #[test]
848 fn scoop_versioned_exe_resolves_to_the_shim() {
849 let tmp = tempfile::tempdir().unwrap();
850 let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
851 let shims = tmp.path().join("shims");
852 for dir in [&apps, &shims] {
853 std::fs::create_dir_all(dir).unwrap();
854 std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
855 }
856 let exe = apps.join("lean-ctx.exe");
857 std::fs::write(
858 shims.join("lean-ctx.shim"),
859 format!("path = \"{}\"\r\n", exe.display()),
860 )
861 .unwrap();
862 assert_eq!(
863 stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
864 sanitize_exe_path(&shims.join("lean-ctx.exe").to_string_lossy())
865 );
866 }
867
868 #[cfg(windows)]
871 #[test]
872 fn unverified_scoop_shim_keeps_the_selected_executable() {
873 let tmp = tempfile::tempdir().unwrap();
874 let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
875 let shims = tmp.path().join("shims");
876 for dir in [&apps, &shims] {
877 std::fs::create_dir_all(dir).unwrap();
878 std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
879 }
880 let exe = apps.join("lean-ctx.exe");
881 assert_eq!(
882 stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
883 exe.to_string_lossy()
884 );
885 }
886
887 #[test]
888 fn agent_binary_is_absolute() {
889 let resolved = resolve_agent_binary();
892 assert!(
893 std::path::Path::new(&resolved).is_absolute(),
894 "resolve_agent_binary must return an absolute path, got: {resolved}"
895 );
896 }
897}