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        if std::path::Path::new(exe).is_absolute() && !is_build_artifact(exe) {
51            return sanitize_exe_path(exe);
52        }
53    }
54
55    // 2. Otherwise fall back to the installed copy on PATH.
56    if let Some(raw) = which_raw {
57        let path = pick_best_binary_line(raw);
58        if std::path::Path::new(&path).is_absolute() {
59            return sanitize_exe_path(&path);
60        }
61    }
62
63    // 3. An absolute build-artifact path still beats a bare name.
64    if let Some(exe) = current_exe {
65        if std::path::Path::new(exe).is_absolute() {
66            return sanitize_exe_path(exe);
67        }
68    }
69
70    // 4. Last resort.
71    "lean-ctx".to_string()
72}
73
74/// On Windows, `where lean-ctx` returns multiple lines (e.g. `lean-ctx` and
75/// `lean-ctx.cmd`). Pick the `.cmd`/`.exe` variant if available, otherwise
76/// the first line.
77fn pick_best_binary_line(raw: &str) -> String {
78    let lines: Vec<&str> = raw
79        .lines()
80        .map(str::trim)
81        .filter(|l| !l.is_empty())
82        .collect();
83    if lines.len() <= 1 {
84        return lines.first().unwrap_or(&"lean-ctx").to_string();
85    }
86    if cfg!(windows) {
87        if let Some(cmd) = lines.iter().find(|l| {
88            std::path::Path::new(*l).extension().is_some_and(|ext| {
89                ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("exe")
90            })
91        }) {
92            return cmd.to_string();
93        }
94    }
95    lines[0].to_string()
96}
97
98fn sanitize_exe_path(path: &str) -> String {
99    let cleaned = path.trim_end_matches(" (deleted)");
100    if cfg!(windows) {
101        super::pathutil::normalize_tool_path(cleaned)
102    } else {
103        cleaned.to_string()
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn single_line_returns_as_is() {
113        assert_eq!(
114            pick_best_binary_line("/usr/bin/lean-ctx"),
115            "/usr/bin/lean-ctx"
116        );
117    }
118
119    #[test]
120    fn multiline_returns_first_line() {
121        let raw = "/usr/bin/lean-ctx\n/usr/local/bin/lean-ctx";
122        let result = pick_best_binary_line(raw);
123        assert_eq!(result, "/usr/bin/lean-ctx");
124    }
125
126    #[test]
127    fn empty_returns_fallback() {
128        assert_eq!(pick_best_binary_line(""), "lean-ctx");
129    }
130
131    #[test]
132    fn sanitize_removes_deleted_suffix() {
133        assert_eq!(
134            sanitize_exe_path("/usr/bin/lean-ctx (deleted)"),
135            "/usr/bin/lean-ctx"
136        );
137    }
138
139    #[test]
140    fn whitespace_lines_are_filtered() {
141        let raw = "  /usr/bin/lean-ctx  \n  \n  /usr/local/bin/lean-ctx  ";
142        assert_eq!(pick_best_binary_line(raw), "/usr/bin/lean-ctx");
143    }
144
145    #[cfg(windows)]
146    #[test]
147    fn sanitize_normalizes_msys_path_on_windows() {
148        assert_eq!(
149            sanitize_exe_path("/c/Users/ABC/.local/bin/lean-ctx"),
150            "C:/Users/ABC/.local/bin/lean-ctx"
151        );
152    }
153
154    #[cfg(windows)]
155    #[test]
156    fn sanitize_keeps_native_windows_path() {
157        assert_eq!(
158            sanitize_exe_path(r"C:\Users\ABC\lean-ctx.exe"),
159            "C:/Users/ABC/lean-ctx.exe"
160        );
161    }
162
163    #[cfg(not(windows))]
164    #[test]
165    fn sanitize_unix_path_unchanged() {
166        assert_eq!(
167            sanitize_exe_path("/usr/local/bin/lean-ctx"),
168            "/usr/local/bin/lean-ctx"
169        );
170    }
171
172    #[test]
173    fn resolve_portable_binary_is_absolute() {
174        // #367: generated hook commands must use an absolute binary path, never
175        // a bare `lean-ctx`, because agents run hooks under non-login shells
176        // without the install dir on PATH. `which`/`current_exe()` both yield
177        // an absolute path in any normal environment (incl. the test harness).
178        let resolved = resolve_portable_binary();
179        assert!(
180            std::path::Path::new(&resolved).is_absolute(),
181            "resolve_portable_binary must return an absolute path, got: {resolved}"
182        );
183    }
184
185    #[test]
186    fn nothing_resolvable_returns_bare_name() {
187        // #2444: neither a usable running binary nor a PATH hit -> bare name.
188        assert_eq!(choose_binary_path(None, None), "lean-ctx");
189        // A relative current_exe is not a usable absolute path.
190        assert_eq!(choose_binary_path(Some("lean-ctx"), None), "lean-ctx");
191    }
192
193    // Unix absolute paths (the `/...` form is not absolute on Windows).
194    #[cfg(not(windows))]
195    mod unix_paths {
196        use super::*;
197
198        #[test]
199        fn current_exe_beats_path_lookup() {
200            // The core of #2444: the *running* build wins over a divergent PATH
201            // entry (e.g. a stale Homebrew copy shadowing ~/.local/bin).
202            let chosen = choose_binary_path(
203                Some("/Users/dev/.local/bin/lean-ctx"),
204                Some("/opt/homebrew/bin/lean-ctx"),
205            );
206            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
207        }
208
209        #[test]
210        fn release_build_artifact_falls_back_to_path() {
211            // `cargo run --release -- setup`: bake the installed copy, not the
212            // transient build output.
213            let chosen = choose_binary_path(
214                Some("/work/lean-ctx/rust/target/release/lean-ctx"),
215                Some("/Users/dev/.local/bin/lean-ctx"),
216            );
217            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
218        }
219
220        #[test]
221        fn debug_build_artifact_falls_back_to_path() {
222            let chosen = choose_binary_path(
223                Some("/work/lean-ctx/rust/target/debug/deps/lean_ctx-abc123"),
224                Some("/usr/local/bin/lean-ctx"),
225            );
226            assert_eq!(chosen, "/usr/local/bin/lean-ctx");
227        }
228
229        #[test]
230        fn build_artifact_without_path_keeps_absolute_current_exe() {
231            // No installed copy on PATH -> an absolute build path still beats the
232            // bare name, so generated hooks stay absolute (#367).
233            let chosen =
234                choose_binary_path(Some("/work/lean-ctx/rust/target/release/lean-ctx"), None);
235            assert_eq!(chosen, "/work/lean-ctx/rust/target/release/lean-ctx");
236        }
237
238        #[test]
239        fn relative_current_exe_falls_back_to_path() {
240            let chosen = choose_binary_path(Some("lean-ctx"), Some("/usr/bin/lean-ctx"));
241            assert_eq!(chosen, "/usr/bin/lean-ctx");
242        }
243
244        #[test]
245        fn path_lookup_multiline_picks_first() {
246            let chosen = choose_binary_path(
247                None,
248                Some("/Users/dev/.local/bin/lean-ctx\n/opt/homebrew/bin/lean-ctx"),
249            );
250            assert_eq!(chosen, "/Users/dev/.local/bin/lean-ctx");
251        }
252    }
253}