1use anyhow::{Context, Result};
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::Target;
29
30#[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#[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#[derive(Debug, Clone)]
55pub struct LinkerCaptureConfig<'a> {
56 pub shim_path: &'a Path,
59 pub cache_dir: &'a Path,
61 pub real_linker: &'a Path,
65}
66
67pub 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 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
121pub 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); }
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
176fn 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
192pub 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 }
203 _ => {
204 map.insert(inv.crate_name.clone(), inv);
205 }
206 }
207}
208
209pub 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
253pub 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
275pub fn default_cache_dir(workspace_root: &Path) -> PathBuf {
278 workspace_root.join("target/.whisker/rustc-args")
279}
280
281pub fn default_linker_cache_dir(workspace_root: &Path) -> PathBuf {
284 workspace_root.join("target/.whisker/linker-args")
285}
286
287pub 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#[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 #[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 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 write_invocation(
430 &dir,
431 &CapturedRustcInvocation {
432 crate_name: "foo".into(),
433 args: s(&["--old-args"]),
434 timestamp_micros: 100,
435 },
436 );
437 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 #[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 #[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 #[test]
580 fn run_fat_build_creates_the_cache_dir_even_if_build_fails() {
581 let dir = unique_tempdir();
585 let cache = dir.join("nested/cache");
586 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 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 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 #[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 #[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 assert!(!p.as_os_str().is_empty());
797 }
798}