whisker_dev_server/hotpatch/shim_paths.rs
1//! Resolve the on-disk paths of `whisker-rustc-shim` and
2//! `whisker-linker-shim` for the dev session.
3//!
4//! Both shims are `[[bin]]` targets of the `whisker-cli` package,
5//! shipped alongside the main `whisker` binary. The dev-server needs
6//! absolute paths to set them as `RUSTC_WORKSPACE_WRAPPER` and
7//! `-C linker=…`.
8//!
9//! Resolution order:
10//!
11//! 1. **Beside the running `whisker` binary** (`current_exe()`'s
12//! dir). `cargo install whisker-cli` installs all three bins into
13//! `~/.cargo/bin` together, so external (crates.io) users resolve
14//! here and never need an in-workspace build. In-workspace dev also
15//! matches once the shims are built (they sit next to
16//! `target/debug/whisker`).
17//! 2. **`<target>/debug/`** — `CARGO_TARGET_DIR` env wins, else
18//! `<workspace>/target`. Covers an in-workspace run whose
19//! `current_exe` lives elsewhere (e.g. invoked via a wrapper).
20//! 3. **Build them** with `cargo build -p whisker-cli --bin … ` from
21//! the workspace, then re-check. Only meaningful in-workspace
22//! (where `whisker-cli` is a member); for external users step 1
23//! already succeeded. A build failure surfaces as `Err(_)` and the
24//! caller falls back to Tier 2.
25
26use anyhow::{Context, Result};
27use std::path::{Path, PathBuf};
28
29/// Absolute paths to both shim binaries.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct ShimPaths {
32 pub rustc_shim: PathBuf,
33 pub linker_shim: PathBuf,
34}
35
36/// Compute the expected shim paths without touching the filesystem.
37/// Pure function — used both in tests and as the first half of
38/// [`resolve_shim_paths`].
39pub fn expected_shim_paths(workspace_root: &Path) -> ShimPaths {
40 let target_dir = std::env::var_os("CARGO_TARGET_DIR")
41 .map(PathBuf::from)
42 .unwrap_or_else(|| workspace_root.join("target"));
43 let bin = |name: &str| target_dir.join("debug").join(exe_name(name));
44 ShimPaths {
45 rustc_shim: bin("whisker-rustc-shim"),
46 linker_shim: bin("whisker-linker-shim"),
47 }
48}
49
50/// Pure helper: append `.exe` on Windows. Pulled out so tests don't
51/// have to fork on cfg.
52pub fn exe_name(name: &str) -> String {
53 if cfg!(windows) {
54 format!("{name}.exe")
55 } else {
56 name.to_string()
57 }
58}
59
60/// The two shim paths as they'd sit inside `dir`. Pure — used both for
61/// the `current_exe()` neighbor lookup and in tests.
62pub fn shim_paths_in_dir(dir: &Path) -> ShimPaths {
63 ShimPaths {
64 rustc_shim: dir.join(exe_name("whisker-rustc-shim")),
65 linker_shim: dir.join(exe_name("whisker-linker-shim")),
66 }
67}
68
69/// The shim paths beside the currently-running executable, if
70/// `current_exe()` resolves. This is where `cargo install whisker-cli`
71/// drops them (all three bins co-located in `~/.cargo/bin`).
72fn shim_paths_beside_current_exe() -> Option<ShimPaths> {
73 let exe = std::env::current_exe().ok()?;
74 Some(shim_paths_in_dir(exe.parent()?))
75}
76
77/// Resolve both shim paths. Build them with `cargo build` if the
78/// binaries aren't on disk yet. Returns absolute paths suitable for
79/// passing to `RUSTC_WORKSPACE_WRAPPER` and `-C linker=…`.
80///
81/// `workspace_root` is the root the build is spawned from; cargo
82/// finds the right `whisker-cli` package via the workspace `members`
83/// declaration.
84pub fn resolve_shim_paths(workspace_root: &Path) -> Result<ShimPaths> {
85 // 1. Beside the running `whisker` binary — the crates.io install
86 // location, and the in-workspace `target/debug` location once
87 // built. Resolving here is what keeps Tier 1 hot reload working
88 // for `cargo install`-only users (no whisker-cli workspace member
89 // to `cargo build`).
90 if let Some(paths) = shim_paths_beside_current_exe() {
91 if paths.rustc_shim.is_file() && paths.linker_shim.is_file() {
92 return Ok(paths);
93 }
94 }
95 // 2. The workspace's target/debug dir (CARGO_TARGET_DIR aware).
96 let paths = expected_shim_paths(workspace_root);
97 if paths.rustc_shim.is_file() && paths.linker_shim.is_file() {
98 return Ok(paths);
99 }
100 // 3. In-workspace fallback: build the shims from the whisker-cli
101 // package. Fails for external users (no such member), who never
102 // reach here because step 1 succeeded.
103 build_shims(workspace_root).context("build whisker-cli shim binaries")?;
104 let paths = expected_shim_paths(workspace_root);
105 anyhow::ensure!(
106 paths.rustc_shim.is_file(),
107 "expected `{}` to exist after `cargo build` of the shims",
108 paths.rustc_shim.display(),
109 );
110 anyhow::ensure!(
111 paths.linker_shim.is_file(),
112 "expected `{}` to exist after `cargo build` of the shims",
113 paths.linker_shim.display(),
114 );
115 Ok(paths)
116}
117
118fn build_shims(workspace_root: &Path) -> Result<()> {
119 // First-run setup: the rustc / linker capture shims are produced
120 // by `cargo build` against `whisker-cli`. Once built, they live
121 // under `target/debug/` and subsequent `whisker run` invocations
122 // skip this step. Stream the cargo output through a step so it
123 // looks like the rest of the initial-build section rather than
124 // dumping ~200 lines of `Compiling X v…` ahead of any UI chrome.
125 let step = whisker_build::ui::step("setup", "whisker-cli shims");
126 let mut cmd = std::process::Command::new("cargo");
127 cmd.args([
128 "build",
129 "-p",
130 "whisker-cli",
131 "--bin",
132 "whisker-rustc-shim",
133 "--bin",
134 "whisker-linker-shim",
135 ])
136 .current_dir(workspace_root);
137 let status = step.pipe(&mut cmd).context("spawn cargo")?;
138 if !status.success() {
139 step.fail(format!("{status}"));
140 anyhow::bail!("cargo exited {status}");
141 }
142 step.done("");
143 Ok(())
144}
145
146// ============================================================================
147// Tests
148// ============================================================================
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 #[test]
155 fn exe_name_appends_dot_exe_on_windows_otherwise_passes_through() {
156 let name = exe_name("foo");
157 if cfg!(windows) {
158 assert_eq!(name, "foo.exe");
159 } else {
160 assert_eq!(name, "foo");
161 }
162 }
163
164 #[test]
165 fn expected_paths_default_to_workspace_target_debug() {
166 // We deliberately don't try to clear CARGO_TARGET_DIR
167 // because env mutations in tests are racy. Instead, accept
168 // both shapes (`<ws>/target/debug/...` or
169 // `<CARGO_TARGET_DIR>/debug/...`) and just sanity-check the
170 // basename + suffix.
171 let p = expected_shim_paths(Path::new("/tmp/ws"));
172 let rustc_basename = p.rustc_shim.file_name().and_then(|n| n.to_str()).unwrap();
173 let linker_basename = p.linker_shim.file_name().and_then(|n| n.to_str()).unwrap();
174 assert_eq!(rustc_basename, exe_name("whisker-rustc-shim"));
175 assert_eq!(linker_basename, exe_name("whisker-linker-shim"));
176 assert!(
177 p.rustc_shim.parent().unwrap().ends_with("debug"),
178 "expected …/debug/, got {}",
179 p.rustc_shim.display(),
180 );
181 assert!(p.linker_shim.parent().unwrap().ends_with("debug"));
182 }
183
184 #[test]
185 fn shim_paths_in_dir_uses_the_given_dir_and_correct_basenames() {
186 let dir = Path::new("/some/install/bin");
187 let p = shim_paths_in_dir(dir);
188 assert_eq!(p.rustc_shim, dir.join(exe_name("whisker-rustc-shim")));
189 assert_eq!(p.linker_shim, dir.join(exe_name("whisker-linker-shim")));
190 }
191
192 #[test]
193 fn resolve_returns_existing_paths_without_rebuilding() {
194 // Set up a fake target dir with the two binaries already
195 // present, point CARGO_TARGET_DIR at it, and verify that
196 // resolve_shim_paths doesn't try to invoke cargo. We can't
197 // really observe "did not invoke cargo" from inside the test
198 // — but we can observe that the call returns Ok cheaply
199 // (no panic, no hang) and the returned paths point at the
200 // files we just created.
201 let dir = unique_tempdir();
202 let target = dir.join("target");
203 std::fs::create_dir_all(target.join("debug")).unwrap();
204 let rustc = target.join("debug").join(exe_name("whisker-rustc-shim"));
205 let linker = target.join("debug").join(exe_name("whisker-linker-shim"));
206 std::fs::write(&rustc, b"#!/bin/sh\nexit 0\n").unwrap();
207 std::fs::write(&linker, b"#!/bin/sh\nexit 0\n").unwrap();
208
209 // Use CARGO_TARGET_DIR so we don't depend on the workspace
210 // layout the tests run from.
211 let prev = std::env::var_os("CARGO_TARGET_DIR");
212 std::env::set_var("CARGO_TARGET_DIR", &target);
213 let result = resolve_shim_paths(&dir);
214 match prev {
215 Some(p) => std::env::set_var("CARGO_TARGET_DIR", p),
216 None => std::env::remove_var("CARGO_TARGET_DIR"),
217 }
218
219 let paths = result.expect("resolve");
220 assert_eq!(paths.rustc_shim, rustc);
221 assert_eq!(paths.linker_shim, linker);
222
223 let _ = std::fs::remove_dir_all(&dir);
224 }
225
226 fn unique_tempdir() -> PathBuf {
227 use std::sync::atomic::{AtomicU64, Ordering};
228 static SEQ: AtomicU64 = AtomicU64::new(0);
229 let n = SEQ.fetch_add(1, Ordering::Relaxed);
230 let pid = std::process::id();
231 let p = std::env::temp_dir().join(format!("whisker-shim-paths-test-{pid}-{n}"));
232 let _ = std::fs::remove_dir_all(&p);
233 std::fs::create_dir_all(&p).unwrap();
234 p
235 }
236}