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_cmd = if cfg!(windows) { "where" } else { "which" };
58 let which_raw = std::process::Command::new(which_cmd)
59 .arg("lean-ctx")
60 .stderr(std::process::Stdio::null())
61 .output()
62 .ok()
63 .filter(|o| o.status.success())
64 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
65 .filter(|s| !s.is_empty());
66
67 choose_binary_path(current.as_deref(), which_raw.as_deref())
68}
69
70const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
72 &["lean-ctx.exe", "lean-ctx.cmd"]
73} else {
74 &["lean-ctx"]
75};
76
77pub fn path_dirs() -> Vec<std::path::PathBuf> {
79 std::env::var_os("PATH")
80 .map(|p| {
81 std::env::split_paths(&p)
82 .filter(|d| !d.as_os_str().is_empty())
83 .collect()
84 })
85 .unwrap_or_default()
86}
87
88pub fn dir_on_path(dir: &std::path::Path, path_dirs: &[std::path::PathBuf]) -> bool {
90 let canonical = std::fs::canonicalize(dir).ok();
91 path_dirs
92 .iter()
93 .any(|d| d == dir || (canonical.is_some() && std::fs::canonicalize(d).ok() == canonical))
94}
95
96pub fn launchers_on_path(path_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
99 path_dirs
100 .iter()
101 .filter_map(|d| {
102 LAUNCHER_NAMES
103 .iter()
104 .map(|name| d.join(name))
105 .find(|p| p.is_file())
106 })
107 .collect()
108}
109
110pub fn stable_shell_binary(binary: &str) -> String {
121 stable_shell_binary_in(binary, &path_dirs())
122}
123
124pub fn resolve_agent_binary() -> String {
133 stable_shell_binary(&resolve_portable_binary())
134}
135
136fn stable_shell_binary_in(binary: &str, path_dirs: &[std::path::PathBuf]) -> String {
137 let path = std::path::Path::new(binary);
138 if !path.is_absolute() || path.parent().is_some_and(|d| dir_on_path(d, path_dirs)) {
139 return binary.to_string();
140 }
141 let Ok(canonical) = std::fs::canonicalize(path) else {
142 return binary.to_string();
143 };
144 launchers_on_path(path_dirs)
145 .iter()
146 .find(|launcher| launcher_runs(launcher, &canonical))
147 .map_or_else(
148 || binary.to_string(),
149 |p| sanitize_exe_path(&p.to_string_lossy()),
150 )
151}
152
153fn launcher_runs(launcher: &std::path::Path, target: &std::path::Path) -> bool {
156 std::fs::canonicalize(launcher).is_ok_and(|l| l == target)
157 || scoop_shim_target(launcher)
158 .is_some_and(|t| std::fs::canonicalize(t).is_ok_and(|t| t == target))
159 || version_manager_shim_owns(launcher, target)
160}
161
162const MAX_SHIM_BYTES: u64 = 4096;
164
165fn scoop_shim_target(launcher: &std::path::Path) -> Option<std::path::PathBuf> {
170 use std::io::Read;
171 let mut text = String::new();
172 std::fs::File::open(launcher.with_extension("shim"))
173 .ok()?
174 .take(MAX_SHIM_BYTES + 1)
175 .read_to_string(&mut text)
176 .ok()?;
177 if text.len() as u64 > MAX_SHIM_BYTES {
178 return None;
179 }
180 text.lines()
181 .find_map(|line| {
182 let (key, value) = line.split_once('=')?;
183 (key.trim() == "path").then(|| std::path::PathBuf::from(value.trim().trim_matches('"')))
184 })
185 .filter(|p| p.is_absolute())
186}
187
188fn version_manager_shim_owns(launcher: &std::path::Path, target: &std::path::Path) -> bool {
193 let Some(shims) = launcher.parent() else {
194 return false;
195 };
196 if shims.file_name() != Some(std::ffi::OsStr::new("shims")) {
197 return false;
198 }
199 shims.parent().is_some_and(|root| {
200 std::fs::canonicalize(root.join("installs"))
201 .is_ok_and(|installs| target.starts_with(installs))
202 })
203}
204
205fn is_cargo_profile_dir(dir: &std::path::Path) -> bool {
210 dir.join(".cargo-lock").is_file()
211 || (dir.join(".fingerprint").is_dir() && dir.join("deps").is_dir())
212}
213
214pub fn in_cargo_output_dir(path: &std::path::Path) -> bool {
224 let Some(dir) = path.parent() else {
225 return false;
226 };
227 if is_cargo_profile_dir(dir) {
228 return true;
229 }
230 matches!(
231 dir.file_name().and_then(|name| name.to_str()),
232 Some("deps" | "examples")
233 ) && dir.parent().is_some_and(is_cargo_profile_dir)
234}
235
236fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
258 let is_build_artifact = |p: &str| {
263 p.split(['/', '\\'])
264 .collect::<Vec<_>>()
265 .windows(2)
266 .any(|w| w[0].ends_with("target") && matches!(w[1], "debug" | "release"))
267 || in_cargo_output_dir(std::path::Path::new(p))
268 };
269
270 if let Some(exe) = current_exe
272 && std::path::Path::new(exe).is_absolute()
273 && !is_build_artifact(exe)
274 {
275 return sanitize_exe_path(exe);
276 }
277
278 if let Some(raw) = which_raw {
280 let path = pick_best_binary_line(raw);
281 if std::path::Path::new(&path).is_absolute() {
282 return sanitize_exe_path(&path);
283 }
284 }
285
286 if let Some(exe) = current_exe
288 && std::path::Path::new(exe).is_absolute()
289 {
290 return sanitize_exe_path(exe);
291 }
292
293 "lean-ctx".to_string()
295}
296
297fn pick_best_binary_line(raw: &str) -> String {
301 let lines: Vec<&str> = raw
302 .lines()
303 .map(str::trim)
304 .filter(|l| !l.is_empty())
305 .collect();
306 if lines.len() <= 1 {
307 return lines.first().unwrap_or(&"lean-ctx").to_string();
308 }
309 if cfg!(windows)
310 && let Some(cmd) = lines.iter().find(|l| {
311 std::path::Path::new(*l).extension().is_some_and(|ext| {
312 ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
313 })
314 })
315 {
316 return cmd.to_string();
317 }
318 lines[0].to_string()
319}
320
321fn sanitize_exe_path(path: &str) -> String {
322 let cleaned = path.trim_end_matches(" (deleted)");
323 if cfg!(windows) {
324 super::pathutil::normalize_tool_path(cleaned)
325 } else {
326 cleaned.to_string()
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn single_line_returns_as_is() {
336 assert_eq!(
337 pick_best_binary_line("/usr/bin/lean-ctx"),
338 "/usr/bin/lean-ctx"
339 );
340 }
341
342 #[test]
343 fn multiline_returns_first_line() {
344 let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
345 let result = pick_best_binary_line(raw);
346 assert_eq!(result, "/usr/bin/lean-ctx");
347 }
348
349 #[test]
350 fn empty_returns_fallback() {
351 assert_eq!(pick_best_binary_line(""), "lean-ctx");
352 }
353
354 #[test]
355 fn sanitize_removes_deleted_suffix() {
356 assert_eq!(
357 sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
358 "/usr/bin/lean-ctx"
359 );
360 }
361
362 #[test]
363 fn whitespace_lines_are_filtered() {
364 let raw = " /usr/bin/lean-ctx \n \n /usr/local/bin/lean-ctx ";
365 assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
366 }
367
368 #[cfg(windows)]
369 #[test]
370 fn sanitize_normalizes_msys_path_on_windows() {
371 assert_eq!(
372 sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
373 "C:/Users/ABC/.local/bin/lean-ctx"
374 );
375 }
376
377 #[cfg(windows)]
378 #[test]
379 fn sanitize_keeps_native_windows_path() {
380 assert_eq!(
381 sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
382 "C:/Users/ABC/lean-ctx.exe"
383 );
384 }
385
386 #[cfg(not(windows))]
387 #[test]
388 fn sanitize_unix_path_unchanged() {
389 assert_eq!(
390 sanitize_exe_path("/usr/local/bin/lean-ctx"),
391 "/usr/local/bin/lean-ctx"
392 );
393 }
394
395 #[test]
399 fn hook_binary_override_env_is_verbatim_and_blank_is_none() {
400 let _lock = crate::core::data_dir::test_env_lock();
401 crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", "$HOME/.local/bin/lean-ctx");
402 assert_eq!(
403 hook_binary_override().as_deref(),
404 Some("$HOME/.local/bin/lean-ctx")
405 );
406 crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", " ");
407 assert_eq!(hook_binary_override(), None);
408 crate::test_env::remove_var("LEAN_CTX_HOOK_BINARY");
409 }
410
411 #[test]
412 fn resolve_portable_binary_is_absolute() {
413 let resolved = resolve_portable_binary();
418 assert!(
419 std::path::Path::new(&resolved).is_absolute(),
420 "resolve_portable_binary must return an absolute path, got: {resolved}"
421 );
422 }
423
424 #[test]
425 fn nothing_resolvable_returns_bare_name() {
426 assert_eq!(choose_binary_path(None, None), "lean-ctx");
428 assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
430 }
431
432 #[cfg(not(windows))]
434 mod unix_paths {
435 use super::*;
436
437 #[test]
438 fn current_exe_beats_path_lookup() {
439 let chosen = choose_binary_path(
442 Some("/Users/dev/.local/bin/lean-ctx"),
443 Some("/opt/homebrew/bin/lean-ctx"),
444 );
445 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
446 }
447
448 #[test]
449 fn release_build_artifact_falls_back_to_path() {
450 let chosen = choose_binary_path(
453 Some("/work/lean-ctx/rust/target/release/lean-ctx"),
454 Some("/Users/dev/.local/bin/lean-ctx"),
455 );
456 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
457 }
458
459 #[test]
460 fn debug_build_artifact_falls_back_to_path() {
461 let chosen = choose_binary_path(
462 Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
463 Some("/usr/local/bin/lean-ctx"),
464 );
465 assert_eq!(chosen, "/usr/local/bin/lean-ctx");
466 }
467
468 #[test]
469 fn relocated_target_dir_falls_back_to_path() {
470 let chosen = choose_binary_path(
473 Some("/Users/dev/.cargo/shared-target/release/lean-ctx"),
474 Some("/Users/dev/.local/bin/lean-ctx"),
475 );
476 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
477 let chosen = choose_binary_path(
479 Some("/opt/lean-ctx/release/lean-ctx"),
480 Some("/Users/dev/.local/bin/lean-ctx"),
481 );
482 assert_eq!(chosen, "/opt/lean-ctx/release/lean-ctx");
483 }
484
485 #[test]
486 fn build_artifact_without_path_keeps_absolute_current_exe() {
487 let chosen =
490 choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
491 assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
492 }
493
494 #[test]
495 fn relative_current_exe_falls_back_to_path() {
496 let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
497 assert_eq!(chosen, "/usr/bin/lean-ctx");
498 }
499
500 #[test]
501 fn path_lookup_multiline_picks_first() {
502 let chosen = choose_binary_path(
503 None,
504 Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
505 );
506 assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
507 }
508 }
509
510 #[cfg(unix)]
513 mod stable_shell_binary {
514 use super::super::*;
515 use std::path::{Path, PathBuf};
516
517 fn launcher(dir: &Path) -> PathBuf {
518 std::fs::create_dir_all(dir).unwrap();
519 let path = dir.join("lean-ctx");
520 std::fs::write(&path, "#!/bin/sh\n").unwrap();
521 path
522 }
523
524 #[test]
525 fn binary_in_a_path_dir_is_kept() {
526 let tmp = tempfile::tempdir().unwrap();
527 let bin = launcher(&tmp.path().join("bin"));
528 let other = launcher(&tmp.path().join("other"));
529 let dirs: Vec<PathBuf> =
530 vec![other.parent().unwrap().into(), bin.parent().unwrap().into()];
531 let bin = bin.to_string_lossy();
532 assert_eq!(stable_shell_binary_in(&bin, &dirs), bin);
533 }
534
535 #[test]
536 fn versioned_install_dir_resolves_to_the_path_launcher() {
537 let tmp = tempfile::tempdir().unwrap();
538 let cellar = launcher(&tmp.path().join("Cellar/lean-ctx/3.10.2/bin"));
539 let unrelated = launcher(&tmp.path().join("other"));
540 let shims = tmp.path().join("shims/lean-ctx");
541 std::fs::create_dir_all(shims.parent().unwrap()).unwrap();
542 std::os::unix::fs::symlink(&cellar, &shims).unwrap();
543 let dirs = vec![
544 unrelated.parent().unwrap().into(),
545 shims.parent().unwrap().into(),
546 ];
547 assert_eq!(
548 stable_shell_binary_in(&cellar.to_string_lossy(), &dirs),
549 shims.to_string_lossy()
550 );
551 }
552
553 #[test]
554 fn symlinked_path_dir_counts_as_on_path() {
555 let tmp = tempfile::tempdir().unwrap();
556 let bin = launcher(&tmp.path().join("real"));
557 let link = tmp.path().join("link");
558 std::os::unix::fs::symlink(bin.parent().unwrap(), &link).unwrap();
559 let bin = bin.to_string_lossy();
560 assert_eq!(stable_shell_binary_in(&bin, &[link]), bin);
561 }
562
563 #[test]
564 fn no_launcher_on_path_keeps_the_binary() {
565 let tmp = tempfile::tempdir().unwrap();
566 let bin = launcher(&tmp.path().join("apps/3.10.2"));
567 let bin = bin.to_string_lossy();
568 assert_eq!(stable_shell_binary_in(&bin, &[tmp.path().join("x")]), bin);
569 assert_eq!(stable_shell_binary_in("lean-ctx", &[]), "lean-ctx");
570 }
571
572 #[test]
573 fn unrelated_public_launcher_keeps_the_selected_binary() {
574 let tmp = tempfile::tempdir().unwrap();
575 let selected = launcher(&tmp.path().join("private-install"));
576 let public = launcher(&tmp.path().join("public-bin"));
577 let selected = selected.to_string_lossy();
578 assert_eq!(
579 stable_shell_binary_in(&selected, &[public.parent().unwrap().into()]),
580 selected
581 );
582 }
583
584 fn shim_file(launcher: &Path, target: &Path) {
585 let body = format!("path = \"{}\"\nargs = \n", target.display());
586 std::fs::write(launcher.with_extension("shim"), body).unwrap();
587 }
588
589 #[test]
590 fn shim_file_naming_the_binary_verifies_the_launcher() {
591 let tmp = tempfile::tempdir().unwrap();
592 let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
593 let shim = launcher(&tmp.path().join("shims"));
594 shim_file(&shim, &exe);
595 assert_eq!(
596 stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
597 shim.to_string_lossy()
598 );
599 }
600
601 #[test]
602 fn shim_file_naming_another_binary_is_ignored() {
603 let tmp = tempfile::tempdir().unwrap();
604 let exe = launcher(&tmp.path().join("private-install"));
605 let public = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
606 let shim = launcher(&tmp.path().join("shims"));
607 shim_file(&shim, &public);
608 let exe = exe.to_string_lossy();
609 assert_eq!(
610 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
611 exe
612 );
613 }
614
615 #[test]
616 fn oversized_shim_file_is_ignored() {
617 let tmp = tempfile::tempdir().unwrap();
618 let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
619 let shim = launcher(&tmp.path().join("shims"));
620 let padding = " ".repeat(MAX_SHIM_BYTES as usize);
621 let body = format!("{padding}\npath = \"{}\"\n", exe.display());
622 std::fs::write(shim.with_extension("shim"), body).unwrap();
623 let exe = exe.to_string_lossy();
624 assert_eq!(
625 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
626 exe
627 );
628 }
629
630 #[test]
631 fn version_manager_shim_owning_the_install_tree_is_used() {
632 let tmp = tempfile::tempdir().unwrap();
633 let data = tmp.path().join("mise");
634 let exe = launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
635 let shim = launcher(&data.join("shims"));
636 assert_eq!(
637 stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
638 shim.to_string_lossy()
639 );
640 }
641
642 #[test]
643 fn version_manager_shim_of_another_tree_is_ignored() {
644 let tmp = tempfile::tempdir().unwrap();
645 let exe = launcher(&tmp.path().join("private/installs/lean-ctx/3.10.4/bin"));
646 let data = tmp.path().join("mise");
647 launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
648 let shim = launcher(&data.join("shims"));
649 let exe = exe.to_string_lossy();
650 assert_eq!(
651 stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
652 exe
653 );
654 }
655 }
656
657 #[cfg(windows)]
660 #[test]
661 fn scoop_versioned_exe_resolves_to_the_shim() {
662 let tmp = tempfile::tempdir().unwrap();
663 let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
664 let shims = tmp.path().join("shims");
665 for dir in [&apps, &shims] {
666 std::fs::create_dir_all(dir).unwrap();
667 std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
668 }
669 let exe = apps.join("lean-ctx.exe");
670 std::fs::write(
671 shims.join("lean-ctx.shim"),
672 format!("path = \"{}\"\r\n", exe.display()),
673 )
674 .unwrap();
675 assert_eq!(
676 stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
677 sanitize_exe_path(&shims.join("lean-ctx.exe").to_string_lossy())
678 );
679 }
680
681 #[cfg(windows)]
684 #[test]
685 fn unverified_scoop_shim_keeps_the_selected_executable() {
686 let tmp = tempfile::tempdir().unwrap();
687 let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
688 let shims = tmp.path().join("shims");
689 for dir in [&apps, &shims] {
690 std::fs::create_dir_all(dir).unwrap();
691 std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
692 }
693 let exe = apps.join("lean-ctx.exe");
694 assert_eq!(
695 stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
696 exe.to_string_lossy()
697 );
698 }
699
700 #[test]
701 fn agent_binary_is_absolute() {
702 let resolved = resolve_agent_binary();
705 assert!(
706 std::path::Path::new(&resolved).is_absolute(),
707 "resolve_agent_binary must return an absolute path, got: {resolved}"
708 );
709 }
710}