Skip to main content

whisker_cli/
rustc_shim.rs

1//! `whisker-rustc-shim` binary's logic.
2//!
3//! Cargo invokes the binary as:
4//!
5//! ```text
6//! whisker-rustc-shim <rustc-path> <rustc-args...>
7//! ```
8//!
9//! when `RUSTC_WORKSPACE_WRAPPER=whisker-rustc-shim` is set. We do two
10//! things, in order:
11//!
12//! 1. Dump the rustc invocation (full argv + crate name + timestamp)
13//!    to JSON at `$WHISKER_RUSTC_CACHE_DIR/<crate>-<microseconds>.json`.
14//!    The dev server reads these later to drive thin rebuilds (I4g-5).
15//! 2. Spawn the *real* rustc with the original args and exit with the
16//!    same status code — to cargo, the wrapper is invisible.
17//!
18//! If `WHISKER_RUSTC_CACHE_DIR` is unset, step 1 is skipped. That way a
19//! stray `RUSTC_WORKSPACE_WRAPPER=whisker-rustc-shim` (left over from a
20//! crashed `whisker run`) doesn't break ordinary `cargo build`.
21
22use anyhow::{Context, Result};
23use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26/// One captured rustc invocation. The dev-server-side `wrapper`
27/// module deserialises one of these per crate to reconstruct the
28/// thin-rebuild command line.
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
30pub struct CapturedRustcInvocation {
31    /// The crate cargo was building when this invocation fired
32    /// (extracted from `--crate-name`). May be empty if rustc was
33    /// invoked without `--crate-name`, e.g. for build-script probes.
34    pub crate_name: String,
35    /// Full argv passed to rustc, **excluding** the rustc binary
36    /// path itself (cargo prepends that, but the rest is what we
37    /// need to replay).
38    pub args: Vec<String>,
39    /// When the invocation happened, as microseconds since UNIX
40    /// epoch. Used to disambiguate multiple invocations of the same
41    /// crate within one fat build.
42    pub timestamp_micros: u128,
43}
44
45/// Entry point called from `src/bin/whisker_rustc_shim.rs`.
46pub fn run() -> Result<()> {
47    let mut argv: Vec<String> = std::env::args().collect();
48    if argv.len() < 2 {
49        anyhow::bail!(
50            "whisker-rustc-shim: expected `<wrapper> <rustc-path> [rustc-args...]`, \
51             got {} arg(s)",
52            argv.len(),
53        );
54    }
55    let _wrapper = argv.remove(0); // own path; not needed
56    let real_rustc = argv.remove(0); // path cargo prepended
57    let rustc_args = argv; // remainder = real rustc args
58
59    // Capture step (silent if no cache dir).
60    if let Some(cache_dir) = std::env::var_os("WHISKER_RUSTC_CACHE_DIR") {
61        let cache_dir = PathBuf::from(cache_dir);
62        let invocation = capture(&rustc_args)?;
63        save_invocation(&cache_dir, &invocation)
64            .with_context(|| format!("save to {}", cache_dir.display()))?;
65    }
66
67    // Forward to real rustc, transparent exit.
68    let status = std::process::Command::new(&real_rustc)
69        .args(&rustc_args)
70        .status()
71        .with_context(|| format!("spawn {real_rustc}"))?;
72    std::process::exit(status.code().unwrap_or(1));
73}
74
75// ----- Pure helpers (testable) ----------------------------------------------
76
77/// Build a [`CapturedRustcInvocation`] from a rustc argv slice.
78/// Pure aside from reading the system clock for the timestamp.
79pub fn capture(rustc_args: &[String]) -> Result<CapturedRustcInvocation> {
80    Ok(CapturedRustcInvocation {
81        crate_name: extract_crate_name(rustc_args).unwrap_or_default(),
82        args: rustc_args.to_vec(),
83        timestamp_micros: SystemTime::now()
84            .duration_since(UNIX_EPOCH)
85            .map(|d| d.as_micros())
86            .unwrap_or(0),
87    })
88}
89
90/// Find the value passed to `--crate-name` in a rustc argv slice.
91/// rustc's CLI guarantees `--crate-name <name>` (separate args) when
92/// cargo invokes it; the equals form (`--crate-name=foo`) isn't used
93/// in practice but we handle it defensively.
94pub fn extract_crate_name(args: &[String]) -> Option<String> {
95    let mut iter = args.iter();
96    while let Some(arg) = iter.next() {
97        if arg == "--crate-name" {
98            return iter.next().cloned();
99        }
100        if let Some(rest) = arg.strip_prefix("--crate-name=") {
101            return Some(rest.to_string());
102        }
103    }
104    None
105}
106
107/// Filesystem-safe filename for a captured invocation. Same crate may
108/// be compiled multiple times in one fat build (e.g. lib + test + bin
109/// targets, build-script vs. main); the timestamp avoids collisions.
110pub fn invocation_filename(invocation: &CapturedRustcInvocation) -> String {
111    let crate_for_path = if invocation.crate_name.is_empty() {
112        "_unknown"
113    } else {
114        invocation.crate_name.as_str()
115    };
116    format!(
117        "{}-{}.json",
118        crate_for_path.replace(['-', '/'], "_"),
119        invocation.timestamp_micros,
120    )
121}
122
123/// Persist `invocation` under `cache_dir/<filename>.json`. Creates
124/// `cache_dir` if missing.
125pub fn save_invocation(cache_dir: &Path, invocation: &CapturedRustcInvocation) -> Result<()> {
126    std::fs::create_dir_all(cache_dir)
127        .with_context(|| format!("create {}", cache_dir.display()))?;
128    let path = cache_dir.join(invocation_filename(invocation));
129    let json = serde_json::to_string_pretty(invocation).context("serialize")?;
130    std::fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
131    Ok(())
132}
133
134// ============================================================================
135// Tests
136// ============================================================================
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::sync::atomic::{AtomicU64, Ordering};
142
143    fn s(v: &[&str]) -> Vec<String> {
144        v.iter().map(|s| s.to_string()).collect()
145    }
146
147    fn unique_tempdir() -> PathBuf {
148        static SEQ: AtomicU64 = AtomicU64::new(0);
149        let n = SEQ.fetch_add(1, Ordering::Relaxed);
150        let pid = std::process::id();
151        let p = std::env::temp_dir().join(format!("whisker-rustc-shim-test-{pid}-{n}"));
152        let _ = std::fs::remove_dir_all(&p);
153        std::fs::create_dir_all(&p).unwrap();
154        p
155    }
156
157    // ----- extract_crate_name ------------------------------------------
158
159    #[test]
160    fn extract_crate_name_from_separated_form() {
161        let args = s(&[
162            "--edition=2021",
163            "--crate-name",
164            "hello_world",
165            "--out-dir",
166            "x",
167        ]);
168        assert_eq!(extract_crate_name(&args).as_deref(), Some("hello_world"));
169    }
170
171    #[test]
172    fn extract_crate_name_from_equals_form() {
173        let args = s(&["--crate-name=foo_bar", "--edition=2021"]);
174        assert_eq!(extract_crate_name(&args).as_deref(), Some("foo_bar"));
175    }
176
177    #[test]
178    fn extract_crate_name_returns_none_when_absent() {
179        let args = s(&["--edition=2021", "--out-dir", "x"]);
180        assert_eq!(extract_crate_name(&args), None);
181    }
182
183    #[test]
184    fn extract_crate_name_first_occurrence_wins() {
185        // Pathological but well-defined: cargo never sends two, but if
186        // it ever did we'd take the first.
187        let args = s(&["--crate-name", "first", "--crate-name", "second"]);
188        assert_eq!(extract_crate_name(&args).as_deref(), Some("first"));
189    }
190
191    // ----- capture -----------------------------------------------------
192
193    #[test]
194    fn capture_includes_full_argv_unchanged() {
195        let argv = s(&[
196            "--crate-name",
197            "demo",
198            "--edition=2021",
199            "src/lib.rs",
200            "-C",
201            "opt-level=0",
202        ]);
203        let inv = capture(&argv).unwrap();
204        assert_eq!(inv.args, argv);
205        assert_eq!(inv.crate_name, "demo");
206        assert!(inv.timestamp_micros > 0);
207    }
208
209    #[test]
210    fn capture_with_no_crate_name_leaves_field_empty() {
211        let inv = capture(&s(&["--edition=2021", "src/lib.rs"])).unwrap();
212        assert_eq!(inv.crate_name, "");
213    }
214
215    // ----- invocation_filename ----------------------------------------
216
217    #[test]
218    fn invocation_filename_uses_underscored_crate_name_and_timestamp() {
219        let inv = CapturedRustcInvocation {
220            crate_name: "hello-world".into(),
221            args: vec![],
222            timestamp_micros: 1_000_000,
223        };
224        assert_eq!(invocation_filename(&inv), "hello_world-1000000.json");
225    }
226
227    #[test]
228    fn invocation_filename_handles_anonymous_crate() {
229        let inv = CapturedRustcInvocation {
230            crate_name: "".into(),
231            args: vec![],
232            timestamp_micros: 42,
233        };
234        assert_eq!(invocation_filename(&inv), "_unknown-42.json");
235    }
236
237    #[test]
238    fn invocation_filename_strips_path_separators() {
239        // Defensive — rustc's --crate-name doesn't contain slashes,
240        // but we shouldn't crater if something weird sneaks in.
241        let inv = CapturedRustcInvocation {
242            crate_name: "weird/name".into(),
243            args: vec![],
244            timestamp_micros: 7,
245        };
246        assert_eq!(invocation_filename(&inv), "weird_name-7.json");
247    }
248
249    // ----- save_invocation (round-trip on disk) -----------------------
250
251    #[test]
252    fn save_invocation_writes_a_readable_json_file() {
253        let dir = unique_tempdir();
254        let inv = CapturedRustcInvocation {
255            crate_name: "x".into(),
256            args: s(&["--crate-name", "x", "src/lib.rs"]),
257            timestamp_micros: 12345,
258        };
259        save_invocation(&dir, &inv).expect("save");
260
261        let path = dir.join(invocation_filename(&inv));
262        assert!(
263            path.is_file(),
264            "json file should exist at {}",
265            path.display()
266        );
267
268        let body = std::fs::read_to_string(&path).unwrap();
269        let parsed: CapturedRustcInvocation = serde_json::from_str(&body).unwrap();
270        assert_eq!(parsed, inv);
271
272        let _ = std::fs::remove_dir_all(&dir);
273    }
274
275    #[test]
276    fn save_invocation_creates_the_cache_dir_if_missing() {
277        let dir = unique_tempdir().join("nested/does/not/exist");
278        assert!(!dir.exists());
279        let inv = CapturedRustcInvocation {
280            crate_name: "x".into(),
281            args: vec![],
282            timestamp_micros: 1,
283        };
284        save_invocation(&dir, &inv).expect("save");
285        assert!(dir.is_dir());
286
287        // cleanup
288        let mut to_remove = dir;
289        for _ in 0..4 {
290            to_remove.pop();
291        }
292        let _ = std::fs::remove_dir_all(&to_remove);
293    }
294}