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 live in the workspace's `whisker-cli` package, so they're
5//! built into the cargo target dir alongside the main `whisker` binary.
6//! The dev-server needs absolute paths to set them as
7//! `RUSTC_WORKSPACE_WRAPPER` and `-C linker=…`.
8//!
9//! Resolution order:
10//!
11//!   1. Compute the expected paths under `<target>/debug/`.
12//!      `target` defaults to `<workspace>/target` but `CARGO_TARGET_DIR`
13//!      env wins (production usage; CI commonly redirects).
14//!   2. If both exist, return them as-is.
15//!   3. Otherwise spawn `cargo build -p whisker-cli --bin whisker-rustc-shim
16//!      --bin whisker-linker-shim` from the workspace, then re-check.
17//!      A build failure surfaces as `Err(_)` — the dev session simply
18//!      cannot run Tier 1 without these binaries.
19
20use anyhow::{Context, Result};
21use std::path::{Path, PathBuf};
22
23/// Absolute paths to both shim binaries.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ShimPaths {
26    pub rustc_shim: PathBuf,
27    pub linker_shim: PathBuf,
28}
29
30/// Compute the expected shim paths without touching the filesystem.
31/// Pure function — used both in tests and as the first half of
32/// [`resolve_shim_paths`].
33pub fn expected_shim_paths(workspace_root: &Path) -> ShimPaths {
34    let target_dir = std::env::var_os("CARGO_TARGET_DIR")
35        .map(PathBuf::from)
36        .unwrap_or_else(|| workspace_root.join("target"));
37    let bin = |name: &str| target_dir.join("debug").join(exe_name(name));
38    ShimPaths {
39        rustc_shim: bin("whisker-rustc-shim"),
40        linker_shim: bin("whisker-linker-shim"),
41    }
42}
43
44/// Pure helper: append `.exe` on Windows. Pulled out so tests don't
45/// have to fork on cfg.
46pub fn exe_name(name: &str) -> String {
47    if cfg!(windows) {
48        format!("{name}.exe")
49    } else {
50        name.to_string()
51    }
52}
53
54/// Resolve both shim paths. Build them with `cargo build` if the
55/// binaries aren't on disk yet. Returns absolute paths suitable for
56/// passing to `RUSTC_WORKSPACE_WRAPPER` and `-C linker=…`.
57///
58/// `workspace_root` is the root the build is spawned from; cargo
59/// finds the right `whisker-cli` package via the workspace `members`
60/// declaration.
61pub fn resolve_shim_paths(workspace_root: &Path) -> Result<ShimPaths> {
62    let paths = expected_shim_paths(workspace_root);
63    if paths.rustc_shim.is_file() && paths.linker_shim.is_file() {
64        return Ok(paths);
65    }
66    build_shims(workspace_root).context("build whisker-cli shim binaries")?;
67    let paths = expected_shim_paths(workspace_root);
68    anyhow::ensure!(
69        paths.rustc_shim.is_file(),
70        "expected `{}` to exist after `cargo build` of the shims",
71        paths.rustc_shim.display(),
72    );
73    anyhow::ensure!(
74        paths.linker_shim.is_file(),
75        "expected `{}` to exist after `cargo build` of the shims",
76        paths.linker_shim.display(),
77    );
78    Ok(paths)
79}
80
81fn build_shims(workspace_root: &Path) -> Result<()> {
82    // First-run setup: the rustc / linker capture shims are produced
83    // by `cargo build` against `whisker-cli`. Once built, they live
84    // under `target/debug/` and subsequent `whisker run` invocations
85    // skip this step. Stream the cargo output through a step so it
86    // looks like the rest of the initial-build section rather than
87    // dumping ~200 lines of `Compiling X v…` ahead of any UI chrome.
88    let step = whisker_build::ui::step("setup", "whisker-cli shims");
89    let mut cmd = std::process::Command::new("cargo");
90    cmd.args([
91        "build",
92        "-p",
93        "whisker-cli",
94        "--bin",
95        "whisker-rustc-shim",
96        "--bin",
97        "whisker-linker-shim",
98    ])
99    .current_dir(workspace_root);
100    let status = step.pipe(&mut cmd).context("spawn cargo")?;
101    if !status.success() {
102        step.fail(format!("{status}"));
103        anyhow::bail!("cargo exited {status}");
104    }
105    step.done("");
106    Ok(())
107}
108
109// ============================================================================
110// Tests
111// ============================================================================
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn exe_name_appends_dot_exe_on_windows_otherwise_passes_through() {
119        let name = exe_name("foo");
120        if cfg!(windows) {
121            assert_eq!(name, "foo.exe");
122        } else {
123            assert_eq!(name, "foo");
124        }
125    }
126
127    #[test]
128    fn expected_paths_default_to_workspace_target_debug() {
129        // We deliberately don't try to clear CARGO_TARGET_DIR
130        // because env mutations in tests are racy. Instead, accept
131        // both shapes (`<ws>/target/debug/...` or
132        // `<CARGO_TARGET_DIR>/debug/...`) and just sanity-check the
133        // basename + suffix.
134        let p = expected_shim_paths(Path::new("/tmp/ws"));
135        let rustc_basename = p.rustc_shim.file_name().and_then(|n| n.to_str()).unwrap();
136        let linker_basename = p.linker_shim.file_name().and_then(|n| n.to_str()).unwrap();
137        assert_eq!(rustc_basename, exe_name("whisker-rustc-shim"));
138        assert_eq!(linker_basename, exe_name("whisker-linker-shim"));
139        assert!(
140            p.rustc_shim.parent().unwrap().ends_with("debug"),
141            "expected …/debug/, got {}",
142            p.rustc_shim.display(),
143        );
144        assert!(p.linker_shim.parent().unwrap().ends_with("debug"));
145    }
146
147    #[test]
148    fn resolve_returns_existing_paths_without_rebuilding() {
149        // Set up a fake target dir with the two binaries already
150        // present, point CARGO_TARGET_DIR at it, and verify that
151        // resolve_shim_paths doesn't try to invoke cargo. We can't
152        // really observe "did not invoke cargo" from inside the test
153        // — but we can observe that the call returns Ok cheaply
154        // (no panic, no hang) and the returned paths point at the
155        // files we just created.
156        let dir = unique_tempdir();
157        let target = dir.join("target");
158        std::fs::create_dir_all(target.join("debug")).unwrap();
159        let rustc = target.join("debug").join(exe_name("whisker-rustc-shim"));
160        let linker = target.join("debug").join(exe_name("whisker-linker-shim"));
161        std::fs::write(&rustc, b"#!/bin/sh\nexit 0\n").unwrap();
162        std::fs::write(&linker, b"#!/bin/sh\nexit 0\n").unwrap();
163
164        // Use CARGO_TARGET_DIR so we don't depend on the workspace
165        // layout the tests run from.
166        let prev = std::env::var_os("CARGO_TARGET_DIR");
167        std::env::set_var("CARGO_TARGET_DIR", &target);
168        let result = resolve_shim_paths(&dir);
169        match prev {
170            Some(p) => std::env::set_var("CARGO_TARGET_DIR", p),
171            None => std::env::remove_var("CARGO_TARGET_DIR"),
172        }
173
174        let paths = result.expect("resolve");
175        assert_eq!(paths.rustc_shim, rustc);
176        assert_eq!(paths.linker_shim, linker);
177
178        let _ = std::fs::remove_dir_all(&dir);
179    }
180
181    fn unique_tempdir() -> PathBuf {
182        use std::sync::atomic::{AtomicU64, Ordering};
183        static SEQ: AtomicU64 = AtomicU64::new(0);
184        let n = SEQ.fetch_add(1, Ordering::Relaxed);
185        let pid = std::process::id();
186        let p = std::env::temp_dir().join(format!("whisker-shim-paths-test-{pid}-{n}"));
187        let _ = std::fs::remove_dir_all(&p);
188        std::fs::create_dir_all(&p).unwrap();
189        p
190    }
191}