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    // The first `lean-ctx` on PATH that is not a test/example binary in a
58    // cargo output directory (#2232).
59    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
67/// Directories of a cargo profile that hold only intermediate artifacts:
68/// test and example binaries, build scripts, incremental state.
69const TRANSIENT_CARGO_DIRS: &[&str] = &["deps", "build", "examples", "incremental"];
70
71/// Whether `path` is an intermediate cargo artifact (#2232): anything under
72/// `target/{debug,release}/{deps,build,examples,incremental}/` or the same
73/// below a `target/<triple>/` directory, or below any other cargo profile
74/// directory recognised by what cargo wrote into it (a `CARGO_TARGET_DIR` of
75/// another name, a named profile).
76///
77/// Such a binary — a `cargo test` harness like `lean_ctx_server-1a2b3c4d` —
78/// disappears with the next build or `cargo clean`, so it must never be baked
79/// into a hook or agent config. A developer's own `target/release/lean-ctx`
80/// sits directly in the profile directory, is not transient, and stays a
81/// valid dev install.
82pub 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
96/// True when writing `binary` into the agent or shell configuration of
97/// `home` would bake a transient cargo artifact into the real account's
98/// configuration (#2232). A test that runs a server or a hook refresh with
99/// the real `HOME` would otherwise point the user's live hooks at a test
100/// binary that the next build deletes.
101///
102/// The real home comes from the account database, not `$HOME`, so tests with
103/// a temporary `HOME` keep exercising the writers. Logs why when it refuses.
104pub 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    // Without an account-database entry there is no real home to protect.
126    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
133/// File names a `lean-ctx` launcher can have inside a `PATH` directory.
134const LAUNCHER_NAMES: &[&str] = if cfg!(windows) {
135    &["lean-ctx.exe", "lean-ctx.cmd"]
136} else {
137    &["lean-ctx"]
138};
139
140/// The directories on `PATH`, in lookup order.
141pub 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
151/// True when `dir` is one of `path_dirs`, directly or through a symlink.
152pub 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
159/// Every `lean-ctx` launcher on `PATH`, in lookup order: the entry a shell
160/// runs for `lean-ctx`, followed by the ones it shadows.
161pub 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
173/// The binary path to embed in machine-local shell artifacts — the shell
174/// hooks, `env.sh` and the `_lc` shims (#1851).
175///
176/// Package managers expose `lean-ctx` on `PATH` through a stable entry (a
177/// Homebrew symlink, a scoop shim, an npm or mise launcher) while the binary
178/// itself lives in a versioned directory off `PATH`. The first launcher on
179/// `PATH` that is verified to run this installation — a symlink to it, a Scoop
180/// shim naming it, a mise/asdf shim owning its install tree — is embedded
181/// instead, so artifacts follow updates. An unrelated build on `PATH` must
182/// never replace an explicitly selected Pro/custom installation.
183pub fn stable_shell_binary(binary: &str) -> String {
184    stable_shell_binary_in(binary, &path_dirs())
185}
186
187/// The binary path to write into agent configurations — MCP server `command`
188/// fields and agent hooks (#1873).
189///
190/// These outlive the running build just like the shell artifacts of #1851: a
191/// Scoop or Homebrew install runs from a versioned directory the next update
192/// removes, which left every configured MCP server pointing at a deleted
193/// binary. A stable launcher is used only when its target identity is verified
194/// (see [`stable_shell_binary`]); otherwise the exact binary is kept.
195pub 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
216/// True when running `launcher` is verified to execute the installation of
217/// `target` (canonical) — never merely because it is named `lean-ctx`.
218fn 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
225/// Upper bound for a Scoop `.shim` file; real ones are one or two lines.
226const MAX_SHIM_BYTES: u64 = 4096;
227
228/// The executable a Scoop shim runs (#1873). Scoop's `shims\lean-ctx.exe` is a
229/// generic launcher that reads `path = "…\apps\lean-ctx\current\lean-ctx.exe"`
230/// from the sibling `lean-ctx.shim`; `current` is a junction to the versioned
231/// directory, so the canonical target is the running build.
232fn 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
251/// mise and asdf keep a `shims` directory next to an `installs` tree
252/// (`<data>/shims/lean-ctx`, `<data>/installs/<tool>/<version>/…`). Their shim
253/// dispatches only into that tree, so it owns every binary inside it and keeps
254/// working after the versioned directory is upgraded away.
255fn 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
268/// Whether `dir` is a directory cargo builds a profile into. Cargo names it
269/// after the profile and puts it wherever `CARGO_TARGET_DIR`, `--target-dir`
270/// or `--target` say, so it is recognised by what cargo writes into it: its
271/// build lock, or the fingerprint and dependency directories.
272fn 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
277/// Whether `path` is an artifact in a cargo output directory on this disk:
278/// directly in a profile directory (`<profile>/lean-ctx`), in its
279/// `deps`/`examples` (test and example binaries), or in a unit's
280/// `<profile>/build/<pkg>/<hash>/out` (where Cargo 1.100 and later put test
281/// binaries).
282///
283/// The one structural answer for every caller that must tell a developer's
284/// build from an installation (uninstall, dev-install, the path baked into
285/// generated artifacts). It reads the disk, so it says "no" for a path that
286/// does not exist; callers keep their name-based check next to it. A wrong
287/// "yes" needs cargo's own bookkeeping files next to an installed binary.
288pub 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    // <profile>/build/<pkg>/<hash>/out, the unit's fingerprint beside `out`.
300    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
312/// Decide which `lean-ctx` path to bake into generated artifacts (autostart
313/// plists, daemon spawn, MCP server command, agent/shell hooks, update
314/// scheduler). The chosen path must be the *exact build the user is running*, so
315/// every artifact agrees and a single `setup`/`dev-install` can never leave the
316/// daemon on a different build than the proxy/MCP config.
317///
318/// Preference order:
319/// 1. `current_exe` when absolute and not inside a transient Cargo build dir —
320///    by construction the build currently in use.
321/// 2. `which lean-ctx` — the installed copy on PATH; used when the running binary
322///    lives in `target/{debug,release}` (`cargo run -- setup`), where the
323///    installed copy is the intended target.
324/// 3. an absolute `current_exe` even from a build dir — still better than a bare
325///    name (keeps generated hooks absolute, see #367).
326/// 4. bare `lean-ctx`.
327///
328/// Prior to #2444 this preferred `which` first, making the baked path depend on
329/// ambient PATH ordering at generation time. That was non-deterministic: the
330/// daemon autostart could capture a stale Homebrew copy shadowing `~/.local/bin`
331/// while the proxy/MCP config captured the fresh build — silently running two
332/// different builds at once.
333fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
334    // `target/{debug,release}` plus a relocated `[build] target-dir` whose name
335    // ends in `target` (e.g. a machine-wide `~/.cargo/shared-target`), by
336    // name; any other cargo output directory (`--target <triple>`, a named
337    // profile, a target dir of another name) by what cargo wrote into it.
338    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    // 1. Prefer the running binary when it lives in a stable install location.
347    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    // 2. Otherwise fall back to the installed copy on PATH — never a test or
355    //    example binary that only happens to be on PATH (#2232).
356    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    // 3. An absolute build-artifact path still beats a bare name.
364    if let Some(exe) = current_exe
365        && std::path::Path::new(exe).is_absolute()
366    {
367        return sanitize_exe_path(exe);
368    }
369
370    // 4. Last resort.
371    "lean-ctx".to_string()
372}
373
374/// On Windows, `where lean-ctx` returns multiple lines (e.g. `lean-ctx` and
375/// `lean-ctx.cmd`). Pick the `.cmd`/`.exe` variant if available, otherwise
376/// the first line.
377fn 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    /// #708: the env override is emitted verbatim — no absolutization, no
473    /// rewriting — so synced settings keep `$HOME/...` forms; blank means
474    /// unset so an empty export cannot wedge hook generation.
475    #[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        // #367: generated hook commands must use an absolute binary path, never
491        // a bare `lean-ctx`, because agents run hooks under non-login shells
492        // without the install dir on PATH. `which`/`current_exe()` both yield
493        // an absolute path in any normal environment (incl. the test harness).
494        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        // #2444: neither a usable running binary nor a PATH hit -> bare name.
504        assert_eq!(choose_binary_path(None, None), "lean-ctx");
505        // A relative current_exe is not a usable absolute path.
506        assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
507    }
508
509    // Unix absolute paths (the `/...` form is not absolute on Windows).
510    #[cfg(not(windows))]
511    mod unix_paths {
512        use super::*;
513
514        #[test]
515        fn current_exe_beats_path_lookup() {
516            // The core of #2444: the *running* build wins over a divergent PATH
517            // entry (e.g. a stale Homebrew copy shadowing ~/.local/bin).
518            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            // `cargo run --release -- setup`: bake the installed copy, not the
528            // transient build output.
529            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            // A machine-wide `[build] target-dir` must not be baked into the
548            // autostart plists: every other worktree's build overwrites it.
549            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            // A bare `release` directory outside any target dir is an install.
555            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            // No installed copy on PATH -> an absolute build path still beats the
565            // bare name, so generated hooks stay absolute (#367).
566            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        /// #2232: test, example and build-script binaries are transient; a
578        /// developer's own build in the profile directory is not.
579        #[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        /// #2232: a cargo output directory of any name, by what cargo wrote.
606        #[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        /// Cargo 1.100 puts test binaries in a unit's
620        /// `<profile>/build/<pkg>/<hash>/out`: still a cargo output
621        /// directory; an `out` without that shape is not.
622        #[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        /// #2232: a test binary that is on PATH is never chosen over the
643        /// running build.
644        #[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        /// #2232: a transient binary is refused for the real account home
654        /// only; temp homes (tests) and stable binaries pass.
655        #[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    /// #1851: package-manager layouts, a versioned install dir off PATH and a
698    /// stable launcher dir on it.
699    #[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    /// #1873: a Scoop install runs from `apps\lean-ctx\<version>`, which the
845    /// next update removes; MCP configs must get the shim on PATH instead.
846    #[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    /// An opaque Windows launcher without a `.shim` naming the running build
869    /// does not establish executable identity.
870    #[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        // #1873: MCP hosts spawn `command` directly, so the stable-launcher
890        // mapping must never degrade the path to a bare name.
891        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}