use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::Target;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct CapturedRustcInvocation {
pub crate_name: String,
pub args: Vec<String>,
pub timestamp_micros: u128,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
pub struct CapturedLinkerInvocation {
pub output: Option<String>,
pub args: Vec<String>,
pub timestamp_micros: u128,
}
#[derive(Debug, Clone)]
pub struct LinkerCaptureConfig<'a> {
pub shim_path: &'a Path,
pub cache_dir: &'a Path,
pub real_linker: &'a Path,
}
pub fn run_fat_build(
workspace_root: &Path,
package: &str,
_target: Target,
shim_path: &Path,
cache_dir: &Path,
linker_capture: Option<&LinkerCaptureConfig<'_>>,
) -> Result<()> {
std::fs::create_dir_all(cache_dir)
.with_context(|| format!("create cache dir {}", cache_dir.display()))?;
let mut cmd = Command::new("cargo");
cmd.args(["build", "-p", package])
.current_dir(workspace_root)
.env("RUSTC_WORKSPACE_WRAPPER", shim_path)
.env("WHISKER_RUSTC_CACHE_DIR", cache_dir);
if let Some(lc) = linker_capture {
std::fs::create_dir_all(lc.cache_dir)
.with_context(|| format!("create linker cache dir {}", lc.cache_dir.display()))?;
let mut rustflags = std::env::var("RUSTFLAGS").unwrap_or_default();
if !rustflags.is_empty() {
rustflags.push(' ');
}
rustflags.push_str(&format!("-Clinker={}", lc.shim_path.display()));
cmd.env("RUSTFLAGS", rustflags)
.env("WHISKER_LINKER_CACHE_DIR", lc.cache_dir)
.env("WHISKER_REAL_LINKER", lc.real_linker);
}
let status = cmd.status().context("spawn cargo for fat build")?;
if !status.success() {
anyhow::bail!("fat build failed: cargo exited {status}");
}
Ok(())
}
pub fn load_captured_args(
cache_dir: &Path,
target_triple_filter: Option<&str>,
) -> Result<HashMap<String, CapturedRustcInvocation>> {
let mut by_crate: HashMap<String, CapturedRustcInvocation> = HashMap::new();
if !cache_dir.is_dir() {
return Ok(by_crate); }
for entry in
std::fs::read_dir(cache_dir).with_context(|| format!("read_dir {}", cache_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let body = match std::fs::read_to_string(&path) {
Ok(b) => b,
Err(e) => {
whisker_build::ui::warn(format!("skip {}: {e}", path.display()));
continue;
}
};
let inv: CapturedRustcInvocation = match serde_json::from_str(&body) {
Ok(i) => i,
Err(e) => {
whisker_build::ui::warn(format!("skip {}: malformed json: {e}", path.display()));
continue;
}
};
if let Some(want) = target_triple_filter {
if invocation_target_triple(&inv) != Some(want) {
continue;
}
}
keep_newest(&mut by_crate, inv);
}
Ok(by_crate)
}
fn invocation_target_triple(inv: &CapturedRustcInvocation) -> Option<&str> {
let mut iter = inv.args.iter();
while let Some(a) = iter.next() {
if a == "--target" {
return iter.next().map(String::as_str);
}
if let Some(rest) = a.strip_prefix("--target=") {
return Some(rest);
}
}
None
}
pub fn keep_newest(
map: &mut HashMap<String, CapturedRustcInvocation>,
inv: CapturedRustcInvocation,
) {
match map.get(&inv.crate_name) {
Some(prev) if prev.timestamp_micros >= inv.timestamp_micros => {
}
_ => {
map.insert(inv.crate_name.clone(), inv);
}
}
}
pub fn load_captured_linker_args(
cache_dir: &Path,
) -> Result<HashMap<String, CapturedLinkerInvocation>> {
let mut by_output: HashMap<String, CapturedLinkerInvocation> = HashMap::new();
if !cache_dir.is_dir() {
return Ok(by_output);
}
for entry in
std::fs::read_dir(cache_dir).with_context(|| format!("read_dir {}", cache_dir.display()))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let body = match std::fs::read_to_string(&path) {
Ok(b) => b,
Err(e) => {
whisker_build::ui::warn(format!("skip {}: {e}", path.display()));
continue;
}
};
let inv: CapturedLinkerInvocation = match serde_json::from_str(&body) {
Ok(i) => i,
Err(e) => {
whisker_build::ui::warn(format!("skip {}: malformed json: {e}", path.display()));
continue;
}
};
keep_newest_linker(&mut by_output, inv);
}
Ok(by_output)
}
pub fn keep_newest_linker(
map: &mut HashMap<String, CapturedLinkerInvocation>,
inv: CapturedLinkerInvocation,
) {
let key = inv
.output
.as_deref()
.and_then(|s| Path::new(s).file_name())
.and_then(|n| n.to_str())
.unwrap_or("_unknown")
.to_string();
match map.get(&key) {
Some(prev) if prev.timestamp_micros >= inv.timestamp_micros => {}
_ => {
map.insert(key, inv);
}
}
}
pub fn default_cache_dir(workspace_root: &Path) -> PathBuf {
workspace_root.join("target/.whisker/rustc-args")
}
pub fn default_linker_cache_dir(workspace_root: &Path) -> PathBuf {
workspace_root.join("target/.whisker/linker-args")
}
pub fn resolve_host_linker() -> PathBuf {
if let Some(cc) = std::env::var_os("CC") {
return PathBuf::from(cc);
}
if cfg!(target_os = "macos") {
if let Ok(out) = Command::new("xcrun").args(["-f", "clang"]).output() {
if out.status.success() {
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !path.is_empty() {
return PathBuf::from(path);
}
}
}
return PathBuf::from("clang");
}
PathBuf::from("cc")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
fn unique_tempdir() -> PathBuf {
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let p = std::env::temp_dir().join(format!("whisker-wrapper-test-{pid}-{n}"));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
p
}
fn write_invocation(dir: &Path, inv: &CapturedRustcInvocation) {
let name = format!(
"{}-{}.json",
inv.crate_name.replace(['-', '/'], "_"),
inv.timestamp_micros,
);
let body = serde_json::to_string_pretty(inv).unwrap();
std::fs::write(dir.join(name), body).unwrap();
}
#[test]
fn load_captured_args_returns_empty_for_missing_cache_dir() {
let map = load_captured_args(Path::new("/nope/does/not/exist"), None).unwrap();
assert!(map.is_empty());
}
#[test]
fn load_captured_args_filters_by_target_triple_when_specified() {
let dir = unique_tempdir();
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "podcast".into(),
args: s(&[
"--crate-name",
"podcast",
"--target",
"aarch64-apple-ios-sim",
]),
timestamp_micros: 100,
},
);
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "podcast".into(),
args: s(&["--crate-name", "podcast", "--target", "x86_64-apple-ios"]),
timestamp_micros: 200,
},
);
let map = load_captured_args(&dir, Some("aarch64-apple-ios-sim")).unwrap();
assert_eq!(map.len(), 1);
assert_eq!(map["podcast"].timestamp_micros, 100);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_args_returns_one_entry_per_crate_for_distinct_crates() {
let dir = unique_tempdir();
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "foo".into(),
args: s(&["--crate-name", "foo", "src/lib.rs"]),
timestamp_micros: 100,
},
);
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "bar".into(),
args: s(&["--crate-name", "bar", "src/lib.rs"]),
timestamp_micros: 200,
},
);
let map = load_captured_args(&dir, None).unwrap();
assert_eq!(map.len(), 2);
assert_eq!(map["foo"].args, s(&["--crate-name", "foo", "src/lib.rs"]));
assert_eq!(map["bar"].args, s(&["--crate-name", "bar", "src/lib.rs"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_args_keeps_the_most_recent_invocation_per_crate() {
let dir = unique_tempdir();
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "foo".into(),
args: s(&["--old-args"]),
timestamp_micros: 100,
},
);
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "foo".into(),
args: s(&["--newer-args", "--more"]),
timestamp_micros: 200,
},
);
let map = load_captured_args(&dir, None).unwrap();
assert_eq!(map.len(), 1);
assert_eq!(map["foo"].timestamp_micros, 200);
assert_eq!(map["foo"].args, s(&["--newer-args", "--more"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_args_skips_non_json_files() {
let dir = unique_tempdir();
std::fs::write(dir.join("README.md"), "not json").unwrap();
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "foo".into(),
args: vec![],
timestamp_micros: 1,
},
);
let map = load_captured_args(&dir, None).unwrap();
assert_eq!(map.len(), 1);
assert!(map.contains_key("foo"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_args_skips_malformed_json_with_a_warning() {
let dir = unique_tempdir();
std::fs::write(dir.join("garbage.json"), "{ not valid json").unwrap();
write_invocation(
&dir,
&CapturedRustcInvocation {
crate_name: "good".into(),
args: vec![],
timestamp_micros: 1,
},
);
let map = load_captured_args(&dir, None).unwrap();
assert_eq!(map.len(), 1);
assert!(map.contains_key("good"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn keep_newest_inserts_into_empty_map() {
let mut m = HashMap::new();
keep_newest(
&mut m,
CapturedRustcInvocation {
crate_name: "x".into(),
args: vec![],
timestamp_micros: 1,
},
);
assert_eq!(m.len(), 1);
}
#[test]
fn keep_newest_replaces_when_timestamp_strictly_newer() {
let mut m = HashMap::new();
m.insert(
"x".into(),
CapturedRustcInvocation {
crate_name: "x".into(),
args: s(&["old"]),
timestamp_micros: 5,
},
);
keep_newest(
&mut m,
CapturedRustcInvocation {
crate_name: "x".into(),
args: s(&["new"]),
timestamp_micros: 10,
},
);
assert_eq!(m["x"].args, s(&["new"]));
}
#[test]
fn keep_newest_does_not_replace_with_equal_or_older_timestamp() {
let mut m = HashMap::new();
m.insert(
"x".into(),
CapturedRustcInvocation {
crate_name: "x".into(),
args: s(&["incumbent"]),
timestamp_micros: 10,
},
);
keep_newest(
&mut m,
CapturedRustcInvocation {
crate_name: "x".into(),
args: s(&["equal"]),
timestamp_micros: 10,
},
);
keep_newest(
&mut m,
CapturedRustcInvocation {
crate_name: "x".into(),
args: s(&["older"]),
timestamp_micros: 1,
},
);
assert_eq!(m["x"].args, s(&["incumbent"]));
}
#[test]
fn default_cache_dir_lives_under_target_dot_whisker() {
let p = default_cache_dir(Path::new("/tmp/ws"));
assert!(p.ends_with("target/.whisker/rustc-args"));
}
#[test]
fn run_fat_build_creates_the_cache_dir_even_if_build_fails() {
let dir = unique_tempdir();
let cache = dir.join("nested/cache");
let bad_workspace = unique_tempdir();
let res = run_fat_build(
&bad_workspace,
"no-such-package",
Target::Android,
Path::new("/bin/true"),
&cache,
None,
);
assert!(res.is_err(), "build of nonexistent pkg should error");
assert!(cache.is_dir(), "cache dir should be created up front");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bad_workspace);
}
#[test]
fn run_fat_build_creates_linker_cache_dir_when_capture_requested() {
let dir = unique_tempdir();
let rustc_cache = dir.join("rustc");
let linker_cache = dir.join("linker");
let bad_workspace = unique_tempdir();
let lc = LinkerCaptureConfig {
shim_path: Path::new("/bin/true"),
cache_dir: &linker_cache,
real_linker: Path::new("/usr/bin/true"),
};
let res = run_fat_build(
&bad_workspace,
"no-such-package",
Target::Android,
Path::new("/bin/true"),
&rustc_cache,
Some(&lc),
);
assert!(res.is_err());
assert!(rustc_cache.is_dir());
assert!(linker_cache.is_dir());
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&bad_workspace);
}
fn write_linker_inv(dir: &Path, inv: &CapturedLinkerInvocation) {
let stem = inv
.output
.as_deref()
.and_then(|s| Path::new(s).file_name())
.and_then(|n| n.to_str())
.unwrap_or("_unknown")
.replace(['/', '\\'], "_");
let name = format!("{stem}-{}.json", inv.timestamp_micros);
let body = serde_json::to_string_pretty(inv).unwrap();
std::fs::write(dir.join(name), body).unwrap();
}
#[test]
fn load_captured_linker_args_returns_empty_for_missing_dir() {
let map = load_captured_linker_args(Path::new("/nope/does/not/exist")).unwrap();
assert!(map.is_empty());
}
#[test]
fn load_captured_linker_args_keys_by_output_basename() {
let dir = unique_tempdir();
write_linker_inv(
&dir,
&CapturedLinkerInvocation {
output: Some("/cargo/target/debug/deps/libfoo.dylib".into()),
args: s(&["-shared", "-o", "/cargo/target/debug/deps/libfoo.dylib"]),
timestamp_micros: 100,
},
);
write_linker_inv(
&dir,
&CapturedLinkerInvocation {
output: Some("/cargo/target/debug/deps/libbar.dylib".into()),
args: s(&["-shared", "-o", "/cargo/target/debug/deps/libbar.dylib"]),
timestamp_micros: 200,
},
);
let map = load_captured_linker_args(&dir).unwrap();
assert_eq!(map.len(), 2);
assert!(map.contains_key("libfoo.dylib"));
assert!(map.contains_key("libbar.dylib"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_linker_args_keeps_most_recent_per_output() {
let dir = unique_tempdir();
write_linker_inv(
&dir,
&CapturedLinkerInvocation {
output: Some("/path/libfoo.dylib".into()),
args: s(&["old"]),
timestamp_micros: 100,
},
);
write_linker_inv(
&dir,
&CapturedLinkerInvocation {
output: Some("/path/libfoo.dylib".into()),
args: s(&["new"]),
timestamp_micros: 200,
},
);
let map = load_captured_linker_args(&dir).unwrap();
assert_eq!(map.len(), 1);
assert_eq!(map["libfoo.dylib"].timestamp_micros, 200);
assert_eq!(map["libfoo.dylib"].args, s(&["new"]));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_captured_linker_args_skips_malformed_json() {
let dir = unique_tempdir();
std::fs::write(dir.join("garbage.json"), "{ not json").unwrap();
write_linker_inv(
&dir,
&CapturedLinkerInvocation {
output: Some("/path/lib.dylib".into()),
args: vec![],
timestamp_micros: 1,
},
);
let map = load_captured_linker_args(&dir).unwrap();
assert_eq!(map.len(), 1);
assert!(map.contains_key("lib.dylib"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn keep_newest_linker_inserts_into_empty() {
let mut m = HashMap::new();
keep_newest_linker(
&mut m,
CapturedLinkerInvocation {
output: Some("/path/lib.so".into()),
args: vec![],
timestamp_micros: 1,
},
);
assert_eq!(m.len(), 1);
assert!(m.contains_key("lib.so"));
}
#[test]
fn keep_newest_linker_does_not_replace_with_older() {
let mut m = HashMap::new();
keep_newest_linker(
&mut m,
CapturedLinkerInvocation {
output: Some("/path/lib.so".into()),
args: s(&["incumbent"]),
timestamp_micros: 10,
},
);
keep_newest_linker(
&mut m,
CapturedLinkerInvocation {
output: Some("/path/lib.so".into()),
args: s(&["older"]),
timestamp_micros: 5,
},
);
assert_eq!(m["lib.so"].args, s(&["incumbent"]));
}
#[test]
fn keep_newest_linker_keys_anonymous_invocations_under_unknown() {
let mut m = HashMap::new();
keep_newest_linker(
&mut m,
CapturedLinkerInvocation {
output: None,
args: vec![],
timestamp_micros: 1,
},
);
assert!(m.contains_key("_unknown"));
}
#[test]
fn default_linker_cache_dir_lives_under_target_dot_whisker() {
let p = default_linker_cache_dir(Path::new("/tmp/ws"));
assert!(p.ends_with("target/.whisker/linker-args"));
}
#[test]
fn resolve_host_linker_returns_something_executable_or_a_path() {
let p = resolve_host_linker();
assert!(!p.as_os_str().is_empty());
}
}