Skip to main content

lean_ctx/core/
portable_binary.rs

1pub fn resolve_portable_binary() -> String {
2    let current = std::env::current_exe()
3        .ok()
4        .map(|p| p.to_string_lossy().into_owned());
5
6    let which_cmd = if cfg!(windows) { "where" } else { "which" };
7    let which_raw = std::process::Command::new(which_cmd)
8        .arg("lean-ctx")
9        .stderr(std::process::Stdio::null())
10        .output()
11        .ok()
12        .filter(|o| o.status.success())
13        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
14        .filter(|s| !s.is_empty());
15
16    choose_binary_path(current.as_deref(), which_raw.as_deref())
17}
18
19/// Decide which `lean-ctx` path to bake into generated artifacts (autostart
20/// plists, daemon spawn, MCP server command, agent/shell hooks, update
21/// scheduler). The chosen path must be the *exact build the user is running*, so
22/// every artifact agrees and a single `setup`/`dev-install` can never leave the
23/// daemon on a different build than the proxy/MCP config.
24///
25/// Preference order:
26/// 1. `current_exe` when absolute and not inside a transient Cargo build dir —
27///    by construction the build currently in use.
28/// 2. `which lean-ctx` — the installed copy on PATH; used when the running binary
29///    lives in `target/{debug,release}` (`cargo run -- setup`), where the
30///    installed copy is the intended target.
31/// 3. an absolute `current_exe` even from a build dir — still better than a bare
32///    name (keeps generated hooks absolute, see #367).
33/// 4. bare `lean-ctx`.
34///
35/// Prior to #2444 this preferred `which` first, making the baked path depend on
36/// ambient PATH ordering at generation time. That was non-deterministic: the
37/// daemon autostart could capture a stale Homebrew copy shadowing `~/.local/bin`
38/// while the proxy/MCP config captured the fresh build — silently running two
39/// different builds at once.
40fn choose_binary_path(current_exe: Option<&str>, which_raw: Option<&str>) -> String {
41    let is_build_artifact = |p: &str| {
42        p.contains("/target/debug/")
43            || p.contains("/target/release/")
44            || p.contains("\\target\\debug\\")
45            || p.contains("\\target\\release\\")
46    };
47
48    // 1. Prefer the running binary when it lives in a stable install location.
49    if let Some(exe) = current_exe
50        && std::path::Path::new(exe).is_absolute()
51        && !is_build_artifact(exe)
52    {
53        return sanitize_exe_path(exe);
54    }
55
56    // 2. Otherwise fall back to the installed copy on PATH.
57    if let Some(raw) = which_raw {
58        let path = pick_best_binary_line(raw);
59        if std::path::Path::new(&path).is_absolute() {
60            return sanitize_exe_path(&path);
61        }
62    }
63
64    // 3. An absolute build-artifact path still beats a bare name.
65    if let Some(exe) = current_exe
66        && std::path::Path::new(exe).is_absolute()
67    {
68        return sanitize_exe_path(exe);
69    }
70
71    // 4. Last resort.
72    "lean-ctx".to_string()
73}
74
75/// On Windows, `where lean-ctx` returns multiple lines (e.g. `lean-ctx` and
76/// `lean-ctx.cmd`). Pick the `.cmd`/`.exe` variant if available, otherwise
77/// the first line.
78fn pick_best_binary_line(raw: &str) -> String {
79    let lines: Vec<&str> = raw
80        .lines()
81        .map(str::trim)
82        .filter(|l| !l.is_empty())
83        .collect();
84    if lines.len() <= 1 {
85        return lines.first().unwrap_or(&"lean-ctx").to_string();
86    }
87    if cfg!(windows)
88        && let Some(cmd) = lines.iter().find(|l| {
89            std::path::Path::new(*l).extension().is_some_and(|ext| {
90                ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
91            })
92        })
93    {
94        return cmd.to_string();
95    }
96    lines[0].to_string()
97}
98
99fn sanitize_exe_path(path: &str) -> String {
100    let cleaned = path.trim_end_matches(" (deleted)");
101    if cfg!(windows) {
102        super::pathutil::normalize_tool_path(cleaned)
103    } else {
104        cleaned.to_string()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn single_line_returns_as_is() {
114        assert_eq!(
115            pick_best_binary_line("/usr/bin/lean-ctx"),
116            "/usr/bin/lean-ctx"
117        );
118    }
119
120    #[test]
121    fn multiline_returns_first_line() {
122        let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
123        let result = pick_best_binary_line(raw);
124        assert_eq!(result, "/usr/bin/lean-ctx");
125    }
126
127    #[test]
128    fn empty_returns_fallback() {
129        assert_eq!(pick_best_binary_line(""), "lean-ctx");
130    }
131
132    #[test]
133    fn sanitize_removes_deleted_suffix() {
134        assert_eq!(
135            sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
136            "/usr/bin/lean-ctx"
137        );
138    }
139
140    #[test]
141    fn whitespace_lines_are_filtered() {
142        let raw = "  /usr/bin/lean-ctx  \n  \n  /usr/local/bin/lean-ctx  ";
143        assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
144    }
145
146    #[cfg(windows)]
147    #[test]
148    fn sanitize_normalizes_msys_path_on_windows() {
149        assert_eq!(
150            sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
151            "C:/Users/ABC/.local/bin/lean-ctx"
152        );
153    }
154
155    #[cfg(windows)]
156    #[test]
157    fn sanitize_keeps_native_windows_path() {
158        assert_eq!(
159            sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
160            "C:/Users/ABC/lean-ctx.exe"
161        );
162    }
163
164    #[cfg(not(windows))]
165    #[test]
166    fn sanitize_unix_path_unchanged() {
167        assert_eq!(
168            sanitize_exe_path("/usr/local/bin/lean-ctx"),
169            "/usr/local/bin/lean-ctx"
170        );
171    }
172
173    #[test]
174    fn resolve_portable_binary_is_absolute() {
175        // #367: generated hook commands must use an absolute binary path, never
176        // a bare `lean-ctx`, because agents run hooks under non-login shells
177        // without the install dir on PATH. `which`/`current_exe()` both yield
178        // an absolute path in any normal environment (incl. the test harness).
179        let resolved = resolve_portable_binary();
180        assert!(
181            std::path::Path::new(&resolved).is_absolute(),
182            "resolve_portable_binary must return an absolute path, got: {resolved}"
183        );
184    }
185
186    #[test]
187    fn nothing_resolvable_returns_bare_name() {
188        // #2444: neither a usable running binary nor a PATH hit -> bare name.
189        assert_eq!(choose_binary_path(None, None), "lean-ctx");
190        // A relative current_exe is not a usable absolute path.
191        assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
192    }
193
194    // Unix absolute paths (the `/...` form is not absolute on Windows).
195    #[cfg(not(windows))]
196    mod unix_paths {
197        use super::*;
198
199        #[test]
200        fn current_exe_beats_path_lookup() {
201            // The core of #2444: the *running* build wins over a divergent PATH
202            // entry (e.g. a stale Homebrew copy shadowing ~/.local/bin).
203            let chosen = choose_binary_path(
204                Some("/Users/dev/.local/bin/lean-ctx"),
205                Some("/opt/homebrew/bin/lean-ctx"),
206            );
207            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
208        }
209
210        #[test]
211        fn release_build_artifact_falls_back_to_path() {
212            // `cargo run --release -- setup`: bake the installed copy, not the
213            // transient build output.
214            let chosen = choose_binary_path(
215                Some("/work/lean-ctx/rust/target/release/lean-ctx"),
216                Some("/Users/dev/.local/bin/lean-ctx"),
217            );
218            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
219        }
220
221        #[test]
222        fn debug_build_artifact_falls_back_to_path() {
223            let chosen = choose_binary_path(
224                Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
225                Some("/usr/local/bin/lean-ctx"),
226            );
227            assert_eq!(chosen, "/usr/local/bin/lean-ctx");
228        }
229
230        #[test]
231        fn build_artifact_without_path_keeps_absolute_current_exe() {
232            // No installed copy on PATH -> an absolute build path still beats the
233            // bare name, so generated hooks stay absolute (#367).
234            let chosen =
235                choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
236            assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
237        }
238
239        #[test]
240        fn relative_current_exe_falls_back_to_path() {
241            let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
242            assert_eq!(chosen, "/usr/bin/lean-ctx");
243        }
244
245        #[test]
246        fn path_lookup_multiline_picks_first() {
247            let chosen = choose_binary_path(
248                None,
249                Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
250            );
251            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
252        }
253    }
254}