Skip to main content

whisker_dev_server/hotpatch/
wrapper.rs

1//! Fat-build runner + captured-args loader.
2//!
3//! The other half of the rustc/linker hijack started in I4g-4a.
4//! `whisker-rustc-shim` writes a JSON file per rustc invocation into a
5//! cache dir; this module:
6//!
7//! 1. Spawns the *fat build* — a normal cargo build with
8//!    `RUSTC_WORKSPACE_WRAPPER=whisker-rustc-shim` set, so the cache
9//!    fills up.
10//! 2. Loads those JSON files back into a `HashMap<String,
11//!    CapturedRustcInvocation>` keyed by crate name, picking the
12//!    most recent timestamp when a crate was rebuilt mid-session.
13//! 3. (Future, I4g-5) hands the captured args to a thin-rebuild
14//!    driver that only recompiles the changed crate and re-links.
15//!
16//! `CapturedRustcInvocation` is currently *defined* here, not in
17//! whisker-cli, so that the shim binary doesn't need to pull in the
18//! whole dev-server dep tree (tokio / axum / notify / object). The
19//! shim has its own copy of the struct shape; serde keeps the wire
20//! format compatible. A future cleanup will extract a tiny
21//! `whisker-hotpatch-types` crate and dedupe both sides — see TODO.
22
23use anyhow::{Context, Result};
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::Target;
29
30/// Mirrors `whisker_cli::rustc_shim::CapturedRustcInvocation` exactly.
31/// Kept duplicated (rather than imported) so the shim binary stays
32/// dep-light. JSON wire format is what binds them — both sides go
33/// through serde, so a field rename in one without the other will
34/// trip the deserialize step at run time and emit a clear error.
35#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
36pub struct CapturedRustcInvocation {
37    pub crate_name: String,
38    pub args: Vec<String>,
39    pub timestamp_micros: u128,
40}
41
42/// Mirrors `whisker_cli::linker_shim::CapturedLinkerInvocation` exactly.
43/// Same duplication rationale as [`CapturedRustcInvocation`].
44#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
45pub struct CapturedLinkerInvocation {
46    pub output: Option<String>,
47    pub args: Vec<String>,
48    pub timestamp_micros: u128,
49}
50
51/// Optional linker-shim wiring for [`run_fat_build`]. Provide all
52/// three when you want the linker invocation captured (Tier 1 needs
53/// it); leave this `None` for plain Tier 2 / Tier 0 fat builds.
54#[derive(Debug, Clone)]
55pub struct LinkerCaptureConfig<'a> {
56    /// Path to `whisker-linker-shim`. The shim is the value rustc sees
57    /// for `-C linker=<…>`.
58    pub shim_path: &'a Path,
59    /// Where the shim writes its JSON cache files.
60    pub cache_dir: &'a Path,
61    /// What the shim forwards to. Typically `xcrun -f clang` on
62    /// macOS or PATH-resolved `cc` on Linux. Required because the
63    /// shim doesn't know the real linker on its own.
64    pub real_linker: &'a Path,
65}
66
67/// Spawn a `cargo` build for the given target with `RUSTC_WORKSPACE_WRAPPER`
68/// pointed at `shim_path` and `WHISKER_RUSTC_CACHE_DIR` pointed at `cache_dir`.
69/// Inherits stdout/stderr so cargo's progress is visible. After the build
70/// completes successfully, [`load_captured_args`] can read the cache.
71///
72/// When `linker_capture` is `Some(_)`, the build *also* installs the
73/// linker shim by setting `RUSTFLAGS=-Clinker=<shim>` plus the
74/// shim's two env vars. The two captures (rustc-args and linker-args)
75/// then both fill up during the same fat build, and Tier 1's
76/// thin_rebuild_obj can replay them together.
77///
78/// `target` is currently a hint only; the cargo command we run is the
79/// host build (`cargo build -p <pkg>`). I4g-5 will switch to the
80/// platform-specific `whisker-build` invocations once thin rebuild
81/// is wired up.
82pub fn run_fat_build(
83    workspace_root: &Path,
84    package: &str,
85    _target: Target,
86    shim_path: &Path,
87    cache_dir: &Path,
88    linker_capture: Option<&LinkerCaptureConfig<'_>>,
89) -> Result<()> {
90    std::fs::create_dir_all(cache_dir)
91        .with_context(|| format!("create cache dir {}", cache_dir.display()))?;
92    let mut cmd = Command::new("cargo");
93    cmd.args(["build", "-p", package])
94        .current_dir(workspace_root)
95        .env("RUSTC_WORKSPACE_WRAPPER", shim_path)
96        .env("WHISKER_RUSTC_CACHE_DIR", cache_dir);
97
98    if let Some(lc) = linker_capture {
99        std::fs::create_dir_all(lc.cache_dir)
100            .with_context(|| format!("create linker cache dir {}", lc.cache_dir.display()))?;
101        // Prepend our -Clinker to any existing RUSTFLAGS rather than
102        // clobbering them — the user may have set their own through
103        // .cargo/config.toml or env.
104        let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
105        if !rustflags.is_empty() {
106            rustflags.push(' ');
107        }
108        rustflags.push_str(&format!("-Clinker={}", lc.shim_path.display()));
109        cmd.env("RUSTFLAGS", rustflags)
110            .env("WHISKER_LINKER_CACHE_DIR", lc.cache_dir)
111            .env("WHISKER_REAL_LINKER", lc.real_linker);
112    }
113
114    let status = cmd.status().context("spawn cargo for fat build")?;
115    if !status.success() {
116        anyhow::bail!("fat build failed: cargo exited {status}");
117    }
118    Ok(())
119}
120
121/// Walk `cache_dir`, deserialise every `*.json` produced by
122/// `whisker-rustc-shim`, and collapse duplicates per crate by keeping
123/// the most-recent timestamp. Empty / unparseable files are skipped
124/// with a warning rather than aborting the whole load — a partial
125/// fat build shouldn't take the dev loop down.
126///
127/// `target_triple_filter` restricts the load to entries whose
128/// captured `--target` matches. Multi-arch fat builds (e.g. iOS Simulator
129/// on Apple Silicon, which xcodebuild compiles for both
130/// `aarch64-apple-ios-sim` and `x86_64-apple-ios`) write one JSON per
131/// (crate, triple) pair; without the filter the newest-timestamp wins
132/// regardless of triple, leaving the patcher likely to pick the wrong
133/// arch and emit an `.o` that fails to link into the runtime dylib
134/// (`found architecture 'x86_64', required architecture 'arm64'`).
135/// Pass `None` to disable filtering (host / single-arch paths).
136pub fn load_captured_args(
137    cache_dir: &Path,
138    target_triple_filter: Option<&str>,
139) -> Result<HashMap<String, CapturedRustcInvocation>> {
140    let mut by_crate: HashMap<String, CapturedRustcInvocation> = HashMap::new();
141    if !cache_dir.is_dir() {
142        return Ok(by_crate); // empty cache is fine, just nothing to do
143    }
144    for entry in
145        std::fs::read_dir(cache_dir).with_context(|| format!("read_dir {}", cache_dir.display()))?
146    {
147        let entry = entry?;
148        let path = entry.path();
149        if path.extension().and_then(|e| e.to_str()) != Some("json") {
150            continue;
151        }
152        let body = match std::fs::read_to_string(&path) {
153            Ok(b) => b,
154            Err(e) => {
155                whisker_build::ui::warn(format!("skip {}: {e}", path.display()));
156                continue;
157            }
158        };
159        let inv: CapturedRustcInvocation = match serde_json::from_str(&body) {
160            Ok(i) => i,
161            Err(e) => {
162                whisker_build::ui::warn(format!("skip {}: malformed json: {e}", path.display()));
163                continue;
164            }
165        };
166        if let Some(want) = target_triple_filter {
167            if invocation_target_triple(&inv) != Some(want) {
168                continue;
169            }
170        }
171        keep_newest(&mut by_crate, inv);
172    }
173    Ok(by_crate)
174}
175
176/// Extract `--target <triple>` from a captured rustc args list, if
177/// present. Returns `None` for host-build invocations (which omit
178/// `--target` and let cargo's default kick in).
179fn invocation_target_triple(inv: &CapturedRustcInvocation) -> Option<&str> {
180    let mut iter = inv.args.iter();
181    while let Some(a) = iter.next() {
182        if a == "--target" {
183            return iter.next().map(String::as_str);
184        }
185        if let Some(rest) = a.strip_prefix("--target=") {
186            return Some(rest);
187        }
188    }
189    None
190}
191
192/// Pure helper for the load loop's "keep most-recent per crate"
193/// decision. Pulled out so unit tests don't have to write JSON to
194/// disk to exercise the merge.
195pub fn keep_newest(
196    map: &mut HashMap<String, CapturedRustcInvocation>,
197    inv: CapturedRustcInvocation,
198) {
199    match map.get(&inv.crate_name) {
200        Some(prev) if prev.timestamp_micros >= inv.timestamp_micros => {
201            // already have a newer or equal-timestamp entry; ignore.
202        }
203        _ => {
204            map.insert(inv.crate_name.clone(), inv);
205        }
206    }
207}
208
209/// Walk a `whisker-linker-shim` cache dir and collapse duplicates per
210/// output filename, keeping the most-recent timestamp. Same shape
211/// as [`load_captured_args`] but for the linker side. Empty /
212/// unparseable files get a warning, not an abort.
213///
214/// Keying by the **basename of the output path** (`libdemo.dylib` →
215/// `libdemo.dylib`) lets the patcher look up the linker call that
216/// produced a specific crate's library without having to re-derive
217/// the cargo's hash-suffixed path. Output-less captures are keyed
218/// under `_unknown`.
219pub fn load_captured_linker_args(
220    cache_dir: &Path,
221) -> Result<HashMap<String, CapturedLinkerInvocation>> {
222    let mut by_output: HashMap<String, CapturedLinkerInvocation> = HashMap::new();
223    if !cache_dir.is_dir() {
224        return Ok(by_output);
225    }
226    for entry in
227        std::fs::read_dir(cache_dir).with_context(|| format!("read_dir {}", cache_dir.display()))?
228    {
229        let entry = entry?;
230        let path = entry.path();
231        if path.extension().and_then(|e| e.to_str()) != Some("json") {
232            continue;
233        }
234        let body = match std::fs::read_to_string(&path) {
235            Ok(b) => b,
236            Err(e) => {
237                whisker_build::ui::warn(format!("skip {}: {e}", path.display()));
238                continue;
239            }
240        };
241        let inv: CapturedLinkerInvocation = match serde_json::from_str(&body) {
242            Ok(i) => i,
243            Err(e) => {
244                whisker_build::ui::warn(format!("skip {}: malformed json: {e}", path.display()));
245                continue;
246            }
247        };
248        keep_newest_linker(&mut by_output, inv);
249    }
250    Ok(by_output)
251}
252
253/// Pure helper for [`load_captured_linker_args`] — same "keep
254/// most-recent per key" pattern as [`keep_newest`], but the key is
255/// the output filename rather than a crate name.
256pub fn keep_newest_linker(
257    map: &mut HashMap<String, CapturedLinkerInvocation>,
258    inv: CapturedLinkerInvocation,
259) {
260    let key = inv
261        .output
262        .as_deref()
263        .and_then(|s| Path::new(s).file_name())
264        .and_then(|n| n.to_str())
265        .unwrap_or("_unknown")
266        .to_string();
267    match map.get(&key) {
268        Some(prev) if prev.timestamp_micros >= inv.timestamp_micros => {}
269        _ => {
270            map.insert(key, inv);
271        }
272    }
273}
274
275/// Convenience: best-effort default cache dir under the workspace's
276/// `target/.whisker/rustc-args/`. Created on demand.
277pub fn default_cache_dir(workspace_root: &Path) -> PathBuf {
278    workspace_root.join("target/.whisker/rustc-args")
279}
280
281/// Counterpart of [`default_cache_dir`] for the linker side:
282/// `target/.whisker/linker-args/`. Created on demand.
283pub fn default_linker_cache_dir(workspace_root: &Path) -> PathBuf {
284    workspace_root.join("target/.whisker/linker-args")
285}
286
287/// Resolve the system linker driver we want to forward to from the
288/// shim. Same logic the integration test uses, lifted into one
289/// place so production and tests agree:
290///
291///   - `CC` env var wins.
292///   - macOS: `xcrun -f clang` (active toolchain).
293///   - Otherwise: PATH-resolved `cc`.
294///
295/// Returns the resolved path. Caller should `.canonicalize()` if
296/// they want absolute, but the shim only needs an executable name
297/// the OS can find.
298pub fn resolve_host_linker() -> PathBuf {
299    if let Some(cc) = std::env::var_os("CC") {
300        return PathBuf::from(cc);
301    }
302    if cfg!(target_os = "macos") {
303        if let Ok(out) = Command::new("xcrun").args(["-f", "clang"]).output() {
304            if out.status.success() {
305                let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
306                if !path.is_empty() {
307                    return PathBuf::from(path);
308                }
309            }
310        }
311        return PathBuf::from("clang");
312    }
313    PathBuf::from("cc")
314}
315
316// ============================================================================
317// Tests
318// ============================================================================
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::sync::atomic::{AtomicU64, Ordering};
324
325    fn s(v: &[&str]) -> Vec<String> {
326        v.iter().map(|s| s.to_string()).collect()
327    }
328
329    fn unique_tempdir() -> PathBuf {
330        static SEQ: AtomicU64 = AtomicU64::new(0);
331        let n = SEQ.fetch_add(1, Ordering::Relaxed);
332        let pid = std::process::id();
333        let p = std::env::temp_dir().join(format!("whisker-wrapper-test-{pid}-{n}"));
334        let _ = std::fs::remove_dir_all(&p);
335        std::fs::create_dir_all(&p).unwrap();
336        p
337    }
338
339    fn write_invocation(dir: &Path, inv: &CapturedRustcInvocation) {
340        let name = format!(
341            "{}-{}.json",
342            inv.crate_name.replace(['-', '/'], "_"),
343            inv.timestamp_micros,
344        );
345        let body = serde_json::to_string_pretty(inv).unwrap();
346        std::fs::write(dir.join(name), body).unwrap();
347    }
348
349    // ----- load_captured_args ------------------------------------------
350
351    #[test]
352    fn load_captured_args_returns_empty_for_missing_cache_dir() {
353        let map = load_captured_args(Path::new("/nope/does/not/exist"), None).unwrap();
354        assert!(map.is_empty());
355    }
356
357    #[test]
358    fn load_captured_args_filters_by_target_triple_when_specified() {
359        // Multi-arch fat build (iOS Simulator on Apple Silicon): one
360        // invocation per triple lands in the cache, both with the same
361        // crate name. Without filtering, the newest-timestamp wins
362        // regardless of triple — which is wrong when the dev-server's
363        // runtime triple is the older one, and the patcher's resulting
364        // `.o` fails to link into the runtime dylib (`found architecture
365        // 'x86_64', required architecture 'arm64'`). Verify the filter
366        // picks the matching triple even when its timestamp is older.
367        let dir = unique_tempdir();
368        write_invocation(
369            &dir,
370            &CapturedRustcInvocation {
371                crate_name: "podcast".into(),
372                args: s(&[
373                    "--crate-name",
374                    "podcast",
375                    "--target",
376                    "aarch64-apple-ios-sim",
377                ]),
378                timestamp_micros: 100,
379            },
380        );
381        write_invocation(
382            &dir,
383            &CapturedRustcInvocation {
384                crate_name: "podcast".into(),
385                args: s(&["--crate-name", "podcast", "--target", "x86_64-apple-ios"]),
386                timestamp_micros: 200,
387            },
388        );
389
390        let map = load_captured_args(&dir, Some("aarch64-apple-ios-sim")).unwrap();
391        assert_eq!(map.len(), 1);
392        assert_eq!(map["podcast"].timestamp_micros, 100);
393
394        let _ = std::fs::remove_dir_all(&dir);
395    }
396
397    #[test]
398    fn load_captured_args_returns_one_entry_per_crate_for_distinct_crates() {
399        let dir = unique_tempdir();
400        write_invocation(
401            &dir,
402            &CapturedRustcInvocation {
403                crate_name: "foo".into(),
404                args: s(&["--crate-name", "foo", "src/lib.rs"]),
405                timestamp_micros: 100,
406            },
407        );
408        write_invocation(
409            &dir,
410            &CapturedRustcInvocation {
411                crate_name: "bar".into(),
412                args: s(&["--crate-name", "bar", "src/lib.rs"]),
413                timestamp_micros: 200,
414            },
415        );
416
417        let map = load_captured_args(&dir, None).unwrap();
418        assert_eq!(map.len(), 2);
419        assert_eq!(map["foo"].args, s(&["--crate-name", "foo", "src/lib.rs"]));
420        assert_eq!(map["bar"].args, s(&["--crate-name", "bar", "src/lib.rs"]));
421
422        let _ = std::fs::remove_dir_all(&dir);
423    }
424
425    #[test]
426    fn load_captured_args_keeps_the_most_recent_invocation_per_crate() {
427        let dir = unique_tempdir();
428        // Older invocation: shorter args.
429        write_invocation(
430            &dir,
431            &CapturedRustcInvocation {
432                crate_name: "foo".into(),
433                args: s(&["--old-args"]),
434                timestamp_micros: 100,
435            },
436        );
437        // Newer invocation: longer args.
438        write_invocation(
439            &dir,
440            &CapturedRustcInvocation {
441                crate_name: "foo".into(),
442                args: s(&["--newer-args", "--more"]),
443                timestamp_micros: 200,
444            },
445        );
446
447        let map = load_captured_args(&dir, None).unwrap();
448        assert_eq!(map.len(), 1);
449        assert_eq!(map["foo"].timestamp_micros, 200);
450        assert_eq!(map["foo"].args, s(&["--newer-args", "--more"]));
451
452        let _ = std::fs::remove_dir_all(&dir);
453    }
454
455    #[test]
456    fn load_captured_args_skips_non_json_files() {
457        let dir = unique_tempdir();
458        std::fs::write(dir.join("README.md"), "not json").unwrap();
459        write_invocation(
460            &dir,
461            &CapturedRustcInvocation {
462                crate_name: "foo".into(),
463                args: vec![],
464                timestamp_micros: 1,
465            },
466        );
467
468        let map = load_captured_args(&dir, None).unwrap();
469        assert_eq!(map.len(), 1);
470        assert!(map.contains_key("foo"));
471
472        let _ = std::fs::remove_dir_all(&dir);
473    }
474
475    #[test]
476    fn load_captured_args_skips_malformed_json_with_a_warning() {
477        let dir = unique_tempdir();
478        std::fs::write(dir.join("garbage.json"), "{ not valid json").unwrap();
479        write_invocation(
480            &dir,
481            &CapturedRustcInvocation {
482                crate_name: "good".into(),
483                args: vec![],
484                timestamp_micros: 1,
485            },
486        );
487
488        let map = load_captured_args(&dir, None).unwrap();
489        assert_eq!(map.len(), 1);
490        assert!(map.contains_key("good"));
491
492        let _ = std::fs::remove_dir_all(&dir);
493    }
494
495    // ----- keep_newest --------------------------------------------------
496
497    #[test]
498    fn keep_newest_inserts_into_empty_map() {
499        let mut m = HashMap::new();
500        keep_newest(
501            &mut m,
502            CapturedRustcInvocation {
503                crate_name: "x".into(),
504                args: vec![],
505                timestamp_micros: 1,
506            },
507        );
508        assert_eq!(m.len(), 1);
509    }
510
511    #[test]
512    fn keep_newest_replaces_when_timestamp_strictly_newer() {
513        let mut m = HashMap::new();
514        m.insert(
515            "x".into(),
516            CapturedRustcInvocation {
517                crate_name: "x".into(),
518                args: s(&["old"]),
519                timestamp_micros: 5,
520            },
521        );
522        keep_newest(
523            &mut m,
524            CapturedRustcInvocation {
525                crate_name: "x".into(),
526                args: s(&["new"]),
527                timestamp_micros: 10,
528            },
529        );
530        assert_eq!(m["x"].args, s(&["new"]));
531    }
532
533    #[test]
534    fn keep_newest_does_not_replace_with_equal_or_older_timestamp() {
535        let mut m = HashMap::new();
536        m.insert(
537            "x".into(),
538            CapturedRustcInvocation {
539                crate_name: "x".into(),
540                args: s(&["incumbent"]),
541                timestamp_micros: 10,
542            },
543        );
544        keep_newest(
545            &mut m,
546            CapturedRustcInvocation {
547                crate_name: "x".into(),
548                args: s(&["equal"]),
549                timestamp_micros: 10,
550            },
551        );
552        keep_newest(
553            &mut m,
554            CapturedRustcInvocation {
555                crate_name: "x".into(),
556                args: s(&["older"]),
557                timestamp_micros: 1,
558            },
559        );
560        assert_eq!(m["x"].args, s(&["incumbent"]));
561    }
562
563    // ----- default_cache_dir -------------------------------------------
564
565    #[test]
566    fn default_cache_dir_lives_under_target_dot_whisker() {
567        let p = default_cache_dir(Path::new("/tmp/ws"));
568        assert!(p.ends_with("target/.whisker/rustc-args"));
569    }
570
571    // ----- run_fat_build (integration: runs a real cargo) ---------------
572    //
573    // Smoke-test only: spawn `cargo --version` instead of a real
574    // build to keep the test fast. The real round-trip
575    // (build → JSON files appear → load_captured_args returns them)
576    // is exercised in I4g-5's integration test against a fixture
577    // crate.
578
579    #[test]
580    fn run_fat_build_creates_the_cache_dir_even_if_build_fails() {
581        // Point the wrapper at /bin/true so cargo doesn't actually
582        // compile anything; we just want to know `run_fat_build`
583        // creates the cache dir and surfaces a non-zero exit as Err.
584        let dir = unique_tempdir();
585        let cache = dir.join("nested/cache");
586        // Bogus workspace_root means cargo build will fail; that's
587        // the path we want to assert on.
588        let bad_workspace = unique_tempdir();
589        let res = run_fat_build(
590            &bad_workspace,
591            "no-such-package",
592            Target::Android,
593            Path::new("/bin/true"),
594            &cache,
595            None,
596        );
597        assert!(res.is_err(), "build of nonexistent pkg should error");
598        assert!(cache.is_dir(), "cache dir should be created up front");
599
600        let _ = std::fs::remove_dir_all(&dir);
601        let _ = std::fs::remove_dir_all(&bad_workspace);
602    }
603
604    #[test]
605    fn run_fat_build_creates_linker_cache_dir_when_capture_requested() {
606        // Same shape as above but with linker_capture set: both
607        // dirs should be created up front, even though the cargo
608        // call ultimately fails.
609        let dir = unique_tempdir();
610        let rustc_cache = dir.join("rustc");
611        let linker_cache = dir.join("linker");
612        let bad_workspace = unique_tempdir();
613        let lc = LinkerCaptureConfig {
614            shim_path: Path::new("/bin/true"),
615            cache_dir: &linker_cache,
616            real_linker: Path::new("/usr/bin/true"),
617        };
618        let res = run_fat_build(
619            &bad_workspace,
620            "no-such-package",
621            Target::Android,
622            Path::new("/bin/true"),
623            &rustc_cache,
624            Some(&lc),
625        );
626        assert!(res.is_err());
627        assert!(rustc_cache.is_dir());
628        assert!(linker_cache.is_dir());
629
630        let _ = std::fs::remove_dir_all(&dir);
631        let _ = std::fs::remove_dir_all(&bad_workspace);
632    }
633
634    // ----- load_captured_linker_args -----------------------------------
635
636    fn write_linker_inv(dir: &Path, inv: &CapturedLinkerInvocation) {
637        let stem = inv
638            .output
639            .as_deref()
640            .and_then(|s| Path::new(s).file_name())
641            .and_then(|n| n.to_str())
642            .unwrap_or("_unknown")
643            .replace(['/', '\\'], "_");
644        let name = format!("{stem}-{}.json", inv.timestamp_micros);
645        let body = serde_json::to_string_pretty(inv).unwrap();
646        std::fs::write(dir.join(name), body).unwrap();
647    }
648
649    #[test]
650    fn load_captured_linker_args_returns_empty_for_missing_dir() {
651        let map = load_captured_linker_args(Path::new("/nope/does/not/exist")).unwrap();
652        assert!(map.is_empty());
653    }
654
655    #[test]
656    fn load_captured_linker_args_keys_by_output_basename() {
657        let dir = unique_tempdir();
658        write_linker_inv(
659            &dir,
660            &CapturedLinkerInvocation {
661                output: Some("/cargo/target/debug/deps/libfoo.dylib".into()),
662                args: s(&["-shared", "-o", "/cargo/target/debug/deps/libfoo.dylib"]),
663                timestamp_micros: 100,
664            },
665        );
666        write_linker_inv(
667            &dir,
668            &CapturedLinkerInvocation {
669                output: Some("/cargo/target/debug/deps/libbar.dylib".into()),
670                args: s(&["-shared", "-o", "/cargo/target/debug/deps/libbar.dylib"]),
671                timestamp_micros: 200,
672            },
673        );
674
675        let map = load_captured_linker_args(&dir).unwrap();
676        assert_eq!(map.len(), 2);
677        assert!(map.contains_key("libfoo.dylib"));
678        assert!(map.contains_key("libbar.dylib"));
679
680        let _ = std::fs::remove_dir_all(&dir);
681    }
682
683    #[test]
684    fn load_captured_linker_args_keeps_most_recent_per_output() {
685        let dir = unique_tempdir();
686        write_linker_inv(
687            &dir,
688            &CapturedLinkerInvocation {
689                output: Some("/path/libfoo.dylib".into()),
690                args: s(&["old"]),
691                timestamp_micros: 100,
692            },
693        );
694        write_linker_inv(
695            &dir,
696            &CapturedLinkerInvocation {
697                output: Some("/path/libfoo.dylib".into()),
698                args: s(&["new"]),
699                timestamp_micros: 200,
700            },
701        );
702
703        let map = load_captured_linker_args(&dir).unwrap();
704        assert_eq!(map.len(), 1);
705        assert_eq!(map["libfoo.dylib"].timestamp_micros, 200);
706        assert_eq!(map["libfoo.dylib"].args, s(&["new"]));
707
708        let _ = std::fs::remove_dir_all(&dir);
709    }
710
711    #[test]
712    fn load_captured_linker_args_skips_malformed_json() {
713        let dir = unique_tempdir();
714        std::fs::write(dir.join("garbage.json"), "{ not json").unwrap();
715        write_linker_inv(
716            &dir,
717            &CapturedLinkerInvocation {
718                output: Some("/path/lib.dylib".into()),
719                args: vec![],
720                timestamp_micros: 1,
721            },
722        );
723
724        let map = load_captured_linker_args(&dir).unwrap();
725        assert_eq!(map.len(), 1);
726        assert!(map.contains_key("lib.dylib"));
727
728        let _ = std::fs::remove_dir_all(&dir);
729    }
730
731    // ----- keep_newest_linker -------------------------------------------
732
733    #[test]
734    fn keep_newest_linker_inserts_into_empty() {
735        let mut m = HashMap::new();
736        keep_newest_linker(
737            &mut m,
738            CapturedLinkerInvocation {
739                output: Some("/path/lib.so".into()),
740                args: vec![],
741                timestamp_micros: 1,
742            },
743        );
744        assert_eq!(m.len(), 1);
745        assert!(m.contains_key("lib.so"));
746    }
747
748    #[test]
749    fn keep_newest_linker_does_not_replace_with_older() {
750        let mut m = HashMap::new();
751        keep_newest_linker(
752            &mut m,
753            CapturedLinkerInvocation {
754                output: Some("/path/lib.so".into()),
755                args: s(&["incumbent"]),
756                timestamp_micros: 10,
757            },
758        );
759        keep_newest_linker(
760            &mut m,
761            CapturedLinkerInvocation {
762                output: Some("/path/lib.so".into()),
763                args: s(&["older"]),
764                timestamp_micros: 5,
765            },
766        );
767        assert_eq!(m["lib.so"].args, s(&["incumbent"]));
768    }
769
770    #[test]
771    fn keep_newest_linker_keys_anonymous_invocations_under_unknown() {
772        let mut m = HashMap::new();
773        keep_newest_linker(
774            &mut m,
775            CapturedLinkerInvocation {
776                output: None,
777                args: vec![],
778                timestamp_micros: 1,
779            },
780        );
781        assert!(m.contains_key("_unknown"));
782    }
783
784    // ----- default_linker_cache_dir + resolve_host_linker --------------
785
786    #[test]
787    fn default_linker_cache_dir_lives_under_target_dot_whisker() {
788        let p = default_linker_cache_dir(Path::new("/tmp/ws"));
789        assert!(p.ends_with("target/.whisker/linker-args"));
790    }
791
792    #[test]
793    fn resolve_host_linker_returns_something_executable_or_a_path() {
794        let p = resolve_host_linker();
795        // We don't `which` here — just sanity that it isn't empty.
796        assert!(!p.as_os_str().is_empty());
797    }
798}