Skip to main content

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}