pub fn hook_binary_override() -> Option<String> {
std::env::var("LEAN_CTX_HOOK_BINARY")
.ok()
.or_else(|| crate::core::config::Config::load().hook_binary.clone())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn is_lean_ctx_hook_binary(binary: &str) -> bool {
let binary = binary.trim().trim_matches(['\'', '"']);
let name = binary.rsplit(['/', '\\']).next().unwrap_or(binary);
matches!(
name,
"lean-ctx" | "lean-ctx.exe" | "lean-ctx.cmd" | "lean-ctx.bat"
) || hook_binary_override()
.is_some_and(|configured| configured.trim_matches(['\'', '"']) == binary)
}
pub fn is_lean_ctx_hook_command(command: &str, operation: &str) -> bool {
let Some((binary, action)) = command.trim().rsplit_once(" hook ") else {
return false;
};
let binary = binary.trim();
let quoted = binary.len() >= 2
&& ((binary.starts_with('"') && binary.ends_with('"'))
|| (binary.starts_with('\'') && binary.ends_with('\'')));
let binary_body = if quoted {
&binary[1..binary.len() - 1]
} else {
binary
};
action == operation
&& !binary_body.contains(['\'', '"', '`'])
&& (quoted || !binary_body.chars().any(char::is_whitespace))
&& is_lean_ctx_hook_binary(binary)
}
pub fn resolve_portable_binary() -> String {
let current = std::env::current_exe()
.ok()
.map(|p| p.to_string_lossy().into_owned());
let which_raw = crate::core::pathutil::find_all_on_path("lean-ctx")
.into_iter()
.map(|p| p.to_string_lossy().into_owned())
.find(|s| !s.is_empty() && !is_transient_build_binary(s));
choose_binary_path(current.as_deref(), which_raw.as_deref())
}
const TRANSIENT_CARGO_DIRS: &[&str] = &["deps", "build", "examples", "incremental"];
pub fn is_transient_build_binary(path: &str) -> bool {
let comps: Vec<&str> = path.split(['/', '\\']).collect();
(1..comps.len().saturating_sub(1)).any(|i| {
if !TRANSIENT_CARGO_DIRS.contains(&comps[i]) {
return false;
}
let profile = comps[i - 1];
let named_profile = matches!(profile, "debug" | "release")
&& ((i >= 2 && comps[i - 2].ends_with("target"))
|| (i >= 3 && comps[i - 3].ends_with("target")));
named_profile || is_cargo_profile_dir(std::path::Path::new(&comps[..i].join("/")))
})
}
pub fn refuses_transient_binary_for_home(binary: &str, home: &std::path::Path) -> bool {
let real_home = crate::core::config::shell_policy::real_user_home();
let refused = transient_binary_for_real_home(binary, home, real_home.as_deref());
if refused {
tracing::warn!(
"not writing transient build binary {binary} into the hook configuration of {}; \
install lean-ctx (or put it on PATH) and run `lean-ctx setup`",
home.display()
);
}
refused
}
fn transient_binary_for_real_home(
binary: &str,
home: &std::path::Path,
real_home: Option<&std::path::Path>,
) -> bool {
if !is_transient_build_binary(binary.trim().trim_matches(['\'', '"'])) {
return false;
}
let Some(real_home) = real_home else {
return false;
};
let canonical = |p: &std::path::Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.into());
canonical(home) == canonical(real_home)
}
const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
&["lean-ctx.exe", "lean-ctx.cmd"]
} else {
&["lean-ctx"]
};
pub fn path_dirs() -> Vec<std::path::PathBuf> {
std::env::var_os("PATH")
.map(|p| {
std::env::split_paths(&p)
.filter(|d| !d.as_os_str().is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn dir_on_path(dir: &std::path::Path, path_dirs: &[std::path::PathBuf]) -> bool {
let canonical = std::fs::canonicalize(dir).ok();
path_dirs
.iter()
.any(|d| d == dir || (canonical.is_some() && std::fs::canonicalize(d).ok() == canonical))
}
pub fn launchers_on_path(path_dirs: &[std::path::PathBuf]) -> Vec<std::path::PathBuf> {
path_dirs
.iter()
.filter_map(|d| {
LAUNCHER_NAMES
.iter()
.map(|name| d.join(name))
.find(|p| p.is_file())
})
.collect()
}
pub fn stable_shell_binary(binary: &str) -> String {
stable_shell_binary_in(binary, &path_dirs())
}
pub fn resolve_agent_binary() -> String {
stable_shell_binary(&resolve_portable_binary())
}
fn stable_shell_binary_in(binary: &str, path_dirs: &[std::path::PathBuf]) -> String {
let path = std::path::Path::new(binary);
if !path.is_absolute() || path.parent().is_some_and(|d| dir_on_path(d, path_dirs)) {
return binary.to_string();
}
let Ok(canonical) = std::fs::canonicalize(path) else {
return binary.to_string();
};
launchers_on_path(path_dirs)
.iter()
.find(|launcher| launcher_runs(launcher, &canonical))
.map_or_else(
|| binary.to_string(),
|p| sanitize_exe_path(&p.to_string_lossy()),
)
}
fn launcher_runs(launcher: &std::path::Path, target: &std::path::Path) -> bool {
std::fs::canonicalize(launcher).is_ok_and(|l| l == target)
|| scoop_shim_target(launcher)
.is_some_and(|t| std::fs::canonicalize(t).is_ok_and(|t| t == target))
|| version_manager_shim_owns(launcher, target)
}
const MAX_SHIM_BYTES: u64 = 4096;
fn scoop_shim_target(launcher: &std::path::Path) -> Option<std::path::PathBuf> {
use std::io::Read;
let mut text = String::new();
std::fs::File::open(launcher.with_extension("shim"))
.ok()?
.take(MAX_SHIM_BYTES + 1)
.read_to_string(&mut text)
.ok()?;
if text.len() as u64 > MAX_SHIM_BYTES {
return None;
}
text.lines()
.find_map(|line| {
let (key, value) = line.split_once('=')?;
(key.trim() == "path").then(|| std::path::PathBuf::from(value.trim().trim_matches('"')))
})
.filter(|p| p.is_absolute())
}
fn version_manager_shim_owns(launcher: &std::path::Path, target: &std::path::Path) -> bool {
let Some(shims) = launcher.parent() else {
return false;
};
if shims.file_name() != Some(std::ffi::OsStr::new("shims")) {
return false;
}
shims.parent().is_some_and(|root| {
std::fs::canonicalize(root.join("installs"))
.is_ok_and(|installs| target.starts_with(installs))
})
}
fn is_cargo_profile_dir(dir: &std::path::Path) -> bool {
dir.join(".cargo-lock").is_file()
|| (dir.join(".fingerprint").is_dir() && dir.join("deps").is_dir())
}
pub fn in_cargo_output_dir(path: &std::path::Path) -> bool {
let Some(dir) = path.parent() else {
return false;
};
if is_cargo_profile_dir(dir) {
return true;
}
matches!(
dir.file_name().and_then(|name| name.to_str()),
Some("deps" | "examples")
) && dir.parent().is_some_and(is_cargo_profile_dir)
}
fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
let is_build_artifact = |p: &str| {
p.split(['/', '\\'])
.collect::<Vec<_>>()
.windows(2)
.any(|w| w[0].ends_with("target") && matches!(w[1], "debug" | "release"))
|| in_cargo_output_dir(std::path::Path::new(p))
};
if let Some(exe) = current_exe
&& std::path::Path::new(exe).is_absolute()
&& !is_build_artifact(exe)
{
return sanitize_exe_path(exe);
}
if let Some(raw) = which_raw {
let path = pick_best_binary_line(raw);
if std::path::Path::new(&path).is_absolute() && !is_transient_build_binary(&path) {
return sanitize_exe_path(&path);
}
}
if let Some(exe) = current_exe
&& std::path::Path::new(exe).is_absolute()
{
return sanitize_exe_path(exe);
}
"lean-ctx".to_string()
}
fn pick_best_binary_line(raw: &str) -> String {
let lines: Vec<&str> = raw
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines.len() <= 1 {
return lines.first().unwrap_or(&"lean-ctx").to_string();
}
if cfg!(windows)
&& let Some(cmd) = lines.iter().find(|l| {
std::path::Path::new(*l).extension().is_some_and(|ext| {
ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
})
})
{
return cmd.to_string();
}
lines[0].to_string()
}
fn sanitize_exe_path(path: &str) -> String {
let cleaned = path.trim_end_matches(" (deleted)");
if cfg!(windows) {
super::pathutil::normalize_tool_path(cleaned)
} else {
cleaned.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn single_line_returns_as_is() {
assert_eq!(
pick_best_binary_line("/usr/bin/lean-ctx"),
"/usr/bin/lean-ctx"
);
}
#[test]
fn multiline_returns_first_line() {
let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
let result = pick_best_binary_line(raw);
assert_eq!(result, "/usr/bin/lean-ctx");
}
#[test]
fn empty_returns_fallback() {
assert_eq!(pick_best_binary_line(""), "lean-ctx");
}
#[test]
fn sanitize_removes_deleted_suffix() {
assert_eq!(
sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
"/usr/bin/lean-ctx"
);
}
#[test]
fn whitespace_lines_are_filtered() {
let raw = " /usr/bin/lean-ctx \n \n /usr/local/bin/lean-ctx ";
assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
}
#[cfg(windows)]
#[test]
fn sanitize_normalizes_msys_path_on_windows() {
assert_eq!(
sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
"C:/Users/ABC/.local/bin/lean-ctx"
);
}
#[cfg(windows)]
#[test]
fn sanitize_keeps_native_windows_path() {
assert_eq!(
sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
"C:/Users/ABC/lean-ctx.exe"
);
}
#[cfg(not(windows))]
#[test]
fn sanitize_unix_path_unchanged() {
assert_eq!(
sanitize_exe_path("/usr/local/bin/lean-ctx"),
"/usr/local/bin/lean-ctx"
);
}
#[test]
fn hook_binary_override_env_is_verbatim_and_blank_is_none() {
let _lock = crate::core::data_dir::test_env_lock();
crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", "$HOME/.local/bin/lean-ctx");
assert_eq!(
hook_binary_override().as_deref(),
Some("$HOME/.local/bin/lean-ctx")
);
crate::test_env::set_var("LEAN_CTX_HOOK_BINARY", " ");
assert_eq!(hook_binary_override(), None);
crate::test_env::remove_var("LEAN_CTX_HOOK_BINARY");
}
#[test]
fn resolve_portable_binary_is_absolute() {
let resolved = resolve_portable_binary();
assert!(
std::path::Path::new(&resolved).is_absolute(),
"resolve_portable_binary must return an absolute path, got: {resolved}"
);
}
#[test]
fn nothing_resolvable_returns_bare_name() {
assert_eq!(choose_binary_path(None, None), "lean-ctx");
assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
}
#[cfg(not(windows))]
mod unix_paths {
use super::*;
#[test]
fn current_exe_beats_path_lookup() {
let chosen = choose_binary_path(
Some("/Users/dev/.local/bin/lean-ctx"),
Some("/opt/homebrew/bin/lean-ctx"),
);
assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
}
#[test]
fn release_build_artifact_falls_back_to_path() {
let chosen = choose_binary_path(
Some("/work/lean-ctx/rust/target/release/lean-ctx"),
Some("/Users/dev/.local/bin/lean-ctx"),
);
assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
}
#[test]
fn debug_build_artifact_falls_back_to_path() {
let chosen = choose_binary_path(
Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
Some("/usr/local/bin/lean-ctx"),
);
assert_eq!(chosen, "/usr/local/bin/lean-ctx");
}
#[test]
fn relocated_target_dir_falls_back_to_path() {
let chosen = choose_binary_path(
Some("/Users/dev/.cargo/shared-target/release/lean-ctx"),
Some("/Users/dev/.local/bin/lean-ctx"),
);
assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
let chosen = choose_binary_path(
Some("/opt/lean-ctx/release/lean-ctx"),
Some("/Users/dev/.local/bin/lean-ctx"),
);
assert_eq!(chosen, "/opt/lean-ctx/release/lean-ctx");
}
#[test]
fn build_artifact_without_path_keeps_absolute_current_exe() {
let chosen =
choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
}
#[test]
fn relative_current_exe_falls_back_to_path() {
let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
assert_eq!(chosen, "/usr/bin/lean-ctx");
}
#[test]
fn transient_build_binaries_are_recognised() {
for transient in [
"/work/lean-ctx/rust/target/debug/deps/lean_ctx_server-1a2b3c4d",
"/work/lean-ctx/rust/target/release/deps/lean_ctx-abc123",
"/work/rust/target/debug/examples/demo",
"/work/rust/target/debug/build/lean-ctx-0f/build-script-build",
"/work/rust/target/debug/incremental/x/lean-ctx",
"/work/rust/target/x86_64-unknown-linux-gnu/debug/deps/lean_ctx-1",
"/Users/dev/.cargo/shared-target/debug/deps/lean_ctx-1",
r"C:\work\rust\target\debug\deps\lean_ctx-1.exe",
] {
assert!(is_transient_build_binary(transient), "{transient}");
}
for stable in [
"/work/lean-ctx/rust/target/release/lean-ctx",
"/work/lean-ctx/rust/target/debug/lean-ctx",
"/Users/dev/.local/bin/lean-ctx",
"/opt/build/deps/lean-ctx",
"/usr/local/bin/lean-ctx",
"lean-ctx",
] {
assert!(!is_transient_build_binary(stable), "{stable}");
}
}
#[test]
fn transient_binary_in_unnamed_target_dir_is_recognised_on_disk() {
let tmp = tempfile::tempdir().unwrap();
let profile = tmp.path().join("target-agent-shared/debug");
std::fs::create_dir_all(profile.join("deps")).unwrap();
std::fs::write(profile.join(".cargo-lock"), "").unwrap();
let test_bin = profile.join("deps/lean_ctx_server-1a2b");
assert!(is_transient_build_binary(&test_bin.to_string_lossy()));
assert!(!is_transient_build_binary(
&profile.join("lean-ctx").to_string_lossy()
));
}
#[test]
fn transient_path_entry_is_not_chosen() {
let chosen = choose_binary_path(
Some("/work/rust/target/debug/deps/lean_ctx_server-1a2b"),
Some("/work/rust/target/debug/deps/lean-ctx"),
);
assert_eq!(chosen, "/work/rust/target/debug/deps/lean_ctx_server-1a2b");
}
#[test]
fn transient_binary_refused_only_for_real_home() {
let tmp = tempfile::tempdir().unwrap();
let real = tmp.path().join("real");
let other = tmp.path().join("other");
std::fs::create_dir_all(&real).unwrap();
std::fs::create_dir_all(&other).unwrap();
let test_bin = "/work/rust/target/debug/deps/lean_ctx_server-1a2b3c4d";
assert!(transient_binary_for_real_home(test_bin, &real, Some(&real)));
assert!(transient_binary_for_real_home(
&format!("\"{test_bin}\""),
&real,
Some(&real)
));
assert!(!transient_binary_for_real_home(
test_bin,
&other,
Some(&real)
));
assert!(!transient_binary_for_real_home(test_bin, &real, None));
assert!(!transient_binary_for_real_home(
"/work/rust/target/release/lean-ctx",
&real,
Some(&real)
));
assert!(!transient_binary_for_real_home(
"/Users/dev/.local/bin/lean-ctx",
&real,
Some(&real)
));
}
#[test]
fn path_lookup_multiline_picks_first() {
let chosen = choose_binary_path(
None,
Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
);
assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
}
}
#[cfg(unix)]
mod stable_shell_binary {
use super::super::*;
use std::path::{Path, PathBuf};
fn launcher(dir: &Path) -> PathBuf {
std::fs::create_dir_all(dir).unwrap();
let path = dir.join("lean-ctx");
std::fs::write(&path, "#!/bin/sh\n").unwrap();
path
}
#[test]
fn binary_in_a_path_dir_is_kept() {
let tmp = tempfile::tempdir().unwrap();
let bin = launcher(&tmp.path().join("bin"));
let other = launcher(&tmp.path().join("other"));
let dirs: Vec<PathBuf> =
vec![other.parent().unwrap().into(), bin.parent().unwrap().into()];
let bin = bin.to_string_lossy();
assert_eq!(stable_shell_binary_in(&bin, &dirs), bin);
}
#[test]
fn versioned_install_dir_resolves_to_the_path_launcher() {
let tmp = tempfile::tempdir().unwrap();
let cellar = launcher(&tmp.path().join("Cellar/lean-ctx/3.10.2/bin"));
let unrelated = launcher(&tmp.path().join("other"));
let shims = tmp.path().join("shims/lean-ctx");
std::fs::create_dir_all(shims.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&cellar, &shims).unwrap();
let dirs = vec![
unrelated.parent().unwrap().into(),
shims.parent().unwrap().into(),
];
assert_eq!(
stable_shell_binary_in(&cellar.to_string_lossy(), &dirs),
shims.to_string_lossy()
);
}
#[test]
fn symlinked_path_dir_counts_as_on_path() {
let tmp = tempfile::tempdir().unwrap();
let bin = launcher(&tmp.path().join("real"));
let link = tmp.path().join("link");
std::os::unix::fs::symlink(bin.parent().unwrap(), &link).unwrap();
let bin = bin.to_string_lossy();
assert_eq!(stable_shell_binary_in(&bin, &[link]), bin);
}
#[test]
fn no_launcher_on_path_keeps_the_binary() {
let tmp = tempfile::tempdir().unwrap();
let bin = launcher(&tmp.path().join("apps/3.10.2"));
let bin = bin.to_string_lossy();
assert_eq!(stable_shell_binary_in(&bin, &[tmp.path().join("x")]), bin);
assert_eq!(stable_shell_binary_in("lean-ctx", &[]), "lean-ctx");
}
#[test]
fn unrelated_public_launcher_keeps_the_selected_binary() {
let tmp = tempfile::tempdir().unwrap();
let selected = launcher(&tmp.path().join("private-install"));
let public = launcher(&tmp.path().join("public-bin"));
let selected = selected.to_string_lossy();
assert_eq!(
stable_shell_binary_in(&selected, &[public.parent().unwrap().into()]),
selected
);
}
fn shim_file(launcher: &Path, target: &Path) {
let body = format!("path = \"{}\"\nargs = \n", target.display());
std::fs::write(launcher.with_extension("shim"), body).unwrap();
}
#[test]
fn shim_file_naming_the_binary_verifies_the_launcher() {
let tmp = tempfile::tempdir().unwrap();
let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
let shim = launcher(&tmp.path().join("shims"));
shim_file(&shim, &exe);
assert_eq!(
stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
shim.to_string_lossy()
);
}
#[test]
fn shim_file_naming_another_binary_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let exe = launcher(&tmp.path().join("private-install"));
let public = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
let shim = launcher(&tmp.path().join("shims"));
shim_file(&shim, &public);
let exe = exe.to_string_lossy();
assert_eq!(
stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
exe
);
}
#[test]
fn oversized_shim_file_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let exe = launcher(&tmp.path().join("apps/lean-ctx/3.10.4"));
let shim = launcher(&tmp.path().join("shims"));
let padding = " ".repeat(MAX_SHIM_BYTES as usize);
let body = format!("{padding}\npath = \"{}\"\n", exe.display());
std::fs::write(shim.with_extension("shim"), body).unwrap();
let exe = exe.to_string_lossy();
assert_eq!(
stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
exe
);
}
#[test]
fn version_manager_shim_owning_the_install_tree_is_used() {
let tmp = tempfile::tempdir().unwrap();
let data = tmp.path().join("mise");
let exe = launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
let shim = launcher(&data.join("shims"));
assert_eq!(
stable_shell_binary_in(&exe.to_string_lossy(), &[shim.parent().unwrap().into()]),
shim.to_string_lossy()
);
}
#[test]
fn version_manager_shim_of_another_tree_is_ignored() {
let tmp = tempfile::tempdir().unwrap();
let exe = launcher(&tmp.path().join("private/installs/lean-ctx/3.10.4/bin"));
let data = tmp.path().join("mise");
launcher(&data.join("installs/lean-ctx/3.10.4/bin"));
let shim = launcher(&data.join("shims"));
let exe = exe.to_string_lossy();
assert_eq!(
stable_shell_binary_in(&exe, &[shim.parent().unwrap().into()]),
exe
);
}
}
#[cfg(windows)]
#[test]
fn scoop_versioned_exe_resolves_to_the_shim() {
let tmp = tempfile::tempdir().unwrap();
let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
let shims = tmp.path().join("shims");
for dir in [&apps, &shims] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
}
let exe = apps.join("lean-ctx.exe");
std::fs::write(
shims.join("lean-ctx.shim"),
format!("path = \"{}\"\r\n", exe.display()),
)
.unwrap();
assert_eq!(
stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
sanitize_exe_path(&shims.join("lean-ctx.exe").to_string_lossy())
);
}
#[cfg(windows)]
#[test]
fn unverified_scoop_shim_keeps_the_selected_executable() {
let tmp = tempfile::tempdir().unwrap();
let apps = tmp.path().join("apps").join("lean-ctx").join("3.10.4");
let shims = tmp.path().join("shims");
for dir in [&apps, &shims] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("lean-ctx.exe"), b"").unwrap();
}
let exe = apps.join("lean-ctx.exe");
assert_eq!(
stable_shell_binary_in(&exe.to_string_lossy(), &[shims.clone()]),
exe.to_string_lossy()
);
}
#[test]
fn agent_binary_is_absolute() {
let resolved = resolve_agent_binary();
assert!(
std::path::Path::new(&resolved).is_absolute(),
"resolve_agent_binary must return an absolute path, got: {resolved}"
);
}
}