Skip to main content

lean_ctx/core/
portable_binary.rs

1/// Verbatim hook-binary override (#708), or `None` when unset/blank.
2///
3/// Users who sync agent settings (`~/.claude/settings.json`, …) across
4/// machines with different usernames set `LEAN_CTX_HOOK_BINARY` (env, wins)
5/// or `hook_binary` (config.toml) to an env-based form like
6/// `$HOME/.local/bin/lean-ctx`. Hook hosts execute commands through a shell,
7/// which expands the variable at run time. The value is emitted verbatim by
8/// every hook writer and accepted verbatim by `doctor`'s staleness check —
9/// scoped to hook/agent artifacts only, never to launchd/systemd units
10/// (which do not expand shell variables).
11pub 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
19/// Identify our hook executable without treating arbitrary user wrappers as owned.
20pub 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
30/// True when `command` is `<our hook binary> hook <operation>`: the binary is
31/// ours ([`is_lean_ctx_hook_binary`]) and carries no shell metacharacters, so
32/// a user wrapper is never mistaken for a LeanCTX hook.
33pub 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
70/// File names a `lean-ctx` launcher can have inside a `PATH` directory.
71const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
72    &["lean-ctx.exe", "lean-ctx.cmd"]
73} else {
74    &["lean-ctx"]
75};
76
77/// The directories on `PATH`, in lookup order.
78pub 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
88/// True when `dir` is one of `path_dirs`, directly or through a symlink.
89pub 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
96/// Every `lean-ctx` launcher on `PATH`, in lookup order: the entry a shell
97/// runs for `lean-ctx`, followed by the ones it shadows.
98pub 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
110/// The binary path to embed in machine-local shell artifacts — the shell
111/// hooks, `env.sh` and the `_lc` shims (#1851).
112///
113/// Package managers expose `lean-ctx` on `PATH` through a stable entry (a
114/// Homebrew symlink, a scoop shim, an npm or mise launcher) while the binary
115/// itself lives in a versioned directory off `PATH`. The first launcher on
116/// `PATH` that is verified to run this installation — a symlink to it, a Scoop
117/// shim naming it, a mise/asdf shim owning its install tree — is embedded
118/// instead, so artifacts follow updates. An unrelated build on `PATH` must
119/// never replace an explicitly selected Pro/custom installation.
120pub fn stable_shell_binary(binary: &str) -> String {
121    stable_shell_binary_in(binary, &path_dirs())
122}
123
124/// The binary path to write into agent configurations — MCP server `command`
125/// fields and agent hooks (#1873).
126///
127/// These outlive the running build just like the shell artifacts of #1851: a
128/// Scoop or Homebrew install runs from a versioned directory the next update
129/// removes, which left every configured MCP server pointing at a deleted
130/// binary. A stable launcher is used only when its target identity is verified
131/// (see [`stable_shell_binary`]); otherwise the exact binary is kept.
132pub 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
153/// True when running `launcher` is verified to execute the installation of
154/// `target` (canonical) — never merely because it is named `lean-ctx`.
155fn 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
162/// Upper bound for a Scoop `.shim` file; real ones are one or two lines.
163const MAX_SHIM_BYTES: u64 = 4096;
164
165/// The executable a Scoop shim runs (#1873). Scoop's `shims\lean-ctx.exe` is a
166/// generic launcher that reads `path = "…\apps\lean-ctx\current\lean-ctx.exe"`
167/// from the sibling `lean-ctx.shim`; `current` is a junction to the versioned
168/// directory, so the canonical target is the running build.
169fn 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
188/// mise and asdf keep a `shims` directory next to an `installs` tree
189/// (`<data>/shims/lean-ctx`, `<data>/installs/<tool>/<version>/…`). Their shim
190/// dispatches only into that tree, so it owns every binary inside it and keeps
191/// working after the versioned directory is upgraded away.
192fn 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
205/// Whether `dir` is a directory cargo builds a profile into. Cargo names it
206/// after the profile and puts it wherever `CARGO_TARGET_DIR`, `--target-dir`
207/// or `--target` say, so it is recognised by what cargo writes into it: its
208/// build lock, or the fingerprint and dependency directories.
209fn 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
214/// Whether `path` is an artifact in a cargo output directory on this disk:
215/// directly in a profile directory (`<profile>/lean-ctx`) or in its
216/// `deps`/`examples` (test and example binaries).
217///
218/// The one structural answer for every caller that must tell a developer's
219/// build from an installation (uninstall, dev-install, the path baked into
220/// generated artifacts). It reads the disk, so it says "no" for a path that
221/// does not exist; callers keep their name-based check next to it. A wrong
222/// "yes" needs cargo's own bookkeeping files next to an installed binary.
223pub 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
236/// Decide which `lean-ctx` path to bake into generated artifacts (autostart
237/// plists, daemon spawn, MCP server command, agent/shell hooks, update
238/// scheduler). The chosen path must be the *exact build the user is running*, so
239/// every artifact agrees and a single `setup`/`dev-install` can never leave the
240/// daemon on a different build than the proxy/MCP config.
241///
242/// Preference order:
243/// 1. `current_exe` when absolute and not inside a transient Cargo build dir —
244///    by construction the build currently in use.
245/// 2. `which lean-ctx` — the installed copy on PATH; used when the running binary
246///    lives in `target/{debug,release}` (`cargo run -- setup`), where the
247///    installed copy is the intended target.
248/// 3. an absolute `current_exe` even from a build dir — still better than a bare
249///    name (keeps generated hooks absolute, see #367).
250/// 4. bare `lean-ctx`.
251///
252/// Prior to #2444 this preferred `which` first, making the baked path depend on
253/// ambient PATH ordering at generation time. That was non-deterministic: the
254/// daemon autostart could capture a stale Homebrew copy shadowing `~/.local/bin`
255/// while the proxy/MCP config captured the fresh build — silently running two
256/// different builds at once.
257fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
258    // `target/{debug,release}` plus a relocated `[build] target-dir` whose name
259    // ends in `target` (e.g. a machine-wide `~/.cargo/shared-target`), by
260    // name; any other cargo output directory (`--target <triple>`, a named
261    // profile, a target dir of another name) by what cargo wrote into it.
262    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    // 1. Prefer the running binary when it lives in a stable install location.
271    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    // 2. Otherwise fall back to the installed copy on PATH.
279    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    // 3. An absolute build-artifact path still beats a bare name.
287    if let Some(exe) = current_exe
288        && std::path::Path::new(exe).is_absolute()
289    {
290        return sanitize_exe_path(exe);
291    }
292
293    // 4. Last resort.
294    "lean-ctx".to_string()
295}
296
297/// On Windows, `where lean-ctx` returns multiple lines (e.g. `lean-ctx` and
298/// `lean-ctx.cmd`). Pick the `.cmd`/`.exe` variant if available, otherwise
299/// the first line.
300fn 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    /// #708: the env override is emitted verbatim — no absolutization, no
396    /// rewriting — so synced settings keep `$HOME/...` forms; blank means
397    /// unset so an empty export cannot wedge hook generation.
398    #[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        // #367: generated hook commands must use an absolute binary path, never
414        // a bare `lean-ctx`, because agents run hooks under non-login shells
415        // without the install dir on PATH. `which`/`current_exe()` both yield
416        // an absolute path in any normal environment (incl. the test harness).
417        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        // #2444: neither a usable running binary nor a PATH hit -> bare name.
427        assert_eq!(choose_binary_path(None, None), "lean-ctx");
428        // A relative current_exe is not a usable absolute path.
429        assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
430    }
431
432    // Unix absolute paths (the `/...` form is not absolute on Windows).
433    #[cfg(not(windows))]
434    mod unix_paths {
435        use super::*;
436
437        #[test]
438        fn current_exe_beats_path_lookup() {
439            // The core of #2444: the *running* build wins over a divergent PATH
440            // entry (e.g. a stale Homebrew copy shadowing ~/.local/bin).
441            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            // `cargo run --release -- setup`: bake the installed copy, not the
451            // transient build output.
452            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            // A machine-wide `[build] target-dir` must not be baked into the
471            // autostart plists: every other worktree's build overwrites it.
472            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            // A bare `release` directory outside any target dir is an install.
478            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            // No installed copy on PATH -> an absolute build path still beats the
488            // bare name, so generated hooks stay absolute (#367).
489            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    /// #1851: package-manager layouts, a versioned install dir off PATH and a
511    /// stable launcher dir on it.
512    #[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    /// #1873: a Scoop install runs from `apps\lean-ctx\<version>`, which the
658    /// next update removes; MCP configs must get the shim on PATH instead.
659    #[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    /// An opaque Windows launcher without a `.shim` naming the running build
682    /// does not establish executable identity.
683    #[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        // #1873: MCP hosts spawn `command` directly, so the stable-launcher
703        // mapping must never degrade the path to a bare name.
704        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}