use std::path::Path;
use std::process::Command;
fn write_project(directory: &Path) {
write_named_project(directory, "fixture");
}
fn write_named_project(directory: &Path, name: &str) {
std::fs::create_dir_all(directory.join("src")).unwrap();
std::fs::write(
directory.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(
directory.join("src/lib.rs"),
"pub fn double(value: u32) -> u32 {\n value * 2\n}\n",
)
.unwrap();
let status = Command::new(cargo())
.current_dir(directory)
.args(["generate-lockfile", "--offline"])
.status()
.expect("cargo should run");
assert!(status.success(), "the fixture should resolve offline");
}
fn write_partially_failing_project(directory: &Path) {
std::fs::create_dir_all(directory.join("good/src")).unwrap();
std::fs::create_dir_all(directory.join("bad/src")).unwrap();
std::fs::write(
directory.join("Cargo.toml"),
"[workspace]\nmembers = [\"good\", \"bad\"]\nresolver = \"2\"\n",
)
.unwrap();
std::fs::write(
directory.join("good/Cargo.toml"),
"[package]\nname = \"good\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(directory.join("good/src/lib.rs"), "pub fn good() {}\n").unwrap();
std::fs::write(
directory.join("bad/Cargo.toml"),
"[package]\nname = \"bad\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\ngood = { path = \"../good\" }\n",
)
.unwrap();
std::fs::write(
directory.join("bad/src/lib.rs"),
"use good as _;\ncompile_error!(\"expected failure\");\n",
)
.unwrap();
let status = Command::new(cargo())
.current_dir(directory)
.args(["generate-lockfile", "--offline"])
.status()
.expect("cargo should run");
assert!(status.success(), "the fixture should resolve offline");
}
fn cargo() -> std::ffi::OsString {
std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}
fn build(project: &Path, store: &Path, report: &Path) -> serde_json::Value {
build_with(project, store, report, &[]).0
}
fn build_with(
project: &Path,
store: &Path,
report: &Path,
settings: &[(&str, &str)],
) -> (serde_json::Value, String) {
let mut command = Command::new(env!("CARGO_BIN_EXE_mbx"));
command
.current_dir(project)
.args(["build", "build", "--offline"])
.env("MBX_CACHE_DIR", store)
.env("MBX_STATS_REPORT", report)
.env_remove("CARGO_TARGET_DIR")
.env_remove("MBX_INCREMENTAL")
.env_remove("CARGO_INCREMENTAL")
.env_remove("CI")
.env_remove("MBX_SHARE_OUT_DIR");
for (name, value) in settings {
command.env(name, value);
}
let output = command.output().expect("mbx should run");
assert!(
output.status.success(),
"build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stats = std::fs::read(report).expect("a statistics report should be written");
(
serde_json::from_slice(&stats).expect("the report should be JSON"),
String::from_utf8_lossy(&output.stderr).into_owned(),
)
}
fn mbx(store: &Path, arguments: &[&str]) -> String {
let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
.args(arguments)
.env("MBX_CACHE_DIR", store)
.output()
.expect("mbx should run");
assert!(
output.status.success(),
"{arguments:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn store_bytes(store: &Path) -> u64 {
tree_bytes(&store.join("actions/cas")) + tree_bytes(&store.join("actions/action-results"))
}
fn tree_bytes(directory: &Path) -> u64 {
let mut total = 0;
let mut pending = vec![directory.to_path_buf()];
while let Some(next) = pending.pop() {
let Ok(listing) = std::fs::read_dir(&next) else {
continue;
};
for entry in listing.flatten() {
let metadata = entry.metadata().expect("the entry should be readable");
if metadata.is_dir() {
pending.push(entry.path());
} else if metadata.is_file() {
total += metadata.len();
}
}
}
total
}
fn count(stats: &serde_json::Value, field: &str) -> u64 {
stats[field]
.as_u64()
.unwrap_or_else(|| panic!("{field} should be a number"))
}
#[test]
fn incremental_is_opt_in_and_reaches_cargo() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
build(
project.path(),
store.path(),
&reports.path().join("default.json"),
);
assert_eq!(
incremental_sessions(project.path()),
0,
"the default build should still force CARGO_INCREMENTAL=0"
);
let (stats, _) = build_with(
project.path(),
store.path(),
&reports.path().join("incremental.json"),
&[("MBX_INCREMENTAL", "1")],
);
assert!(
incremental_sessions(project.path()) > 0,
"cargo should have compiled the member incrementally: {stats}"
);
}
fn incremental_sessions(project: &Path) -> usize {
match std::fs::read_dir(project.join("target/debug/incremental")) {
Ok(entries) => entries.count(),
Err(_) => 0,
}
}
#[test]
fn restores_a_wiped_target_directory_from_the_store() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
let cold = build(
project.path(),
store.path(),
&reports.path().join("cold.json"),
);
assert_eq!(count(&cold, "hits"), 0, "a cold build cannot hit");
assert!(
count(&cold, "stored_bytes") > 0,
"a cold build should publish its outputs: {cold}"
);
assert_eq!(
count(&cold, "misses"),
0,
"a cold build looks nothing up, so it cannot miss: {cold}"
);
assert!(
count(&cold, "unconsulted") > 0,
"a cold build should report the compilations it had no key for: {cold}"
);
std::fs::remove_dir_all(project.path().join("target")).unwrap();
let warm = build(
project.path(),
store.path(),
&reports.path().join("warm.json"),
);
assert!(
count(&warm, "hits") > 0,
"the rebuilt target directory should be restored: {warm}"
);
assert!(
project.path().join("target/debug/libfixture.rlib").exists(),
"the restored artifact should be materialized: {warm}"
);
assert_eq!(
count(&warm, "compiler_invocations_avoided"),
count(&warm, "hits")
);
}
#[test]
fn a_second_checkout_starts_warm() {
let store = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(first.path());
write_project(second.path());
build(
first.path(),
store.path(),
&reports.path().join("first.json"),
);
let warm = build(
second.path(),
store.path(),
&reports.path().join("second.json"),
);
assert!(
count(&warm, "hits") > 0,
"a checkout at another path should reuse the first build: {warm}"
);
}
#[derive(Clone, Copy)]
enum Generated {
Cfg,
Include,
Text,
}
fn write_generated_project(directory: &Path, generated: Generated) {
std::fs::create_dir_all(directory.join("src")).unwrap();
std::fs::write(
directory.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n [lints.rust]\nunexpected_cfgs = { level = \"allow\" }\n",
)
.unwrap();
std::fs::write(
directory.join("build.rs"),
"use std::{env, fs, path::PathBuf};\n fn main() {\n \u{20} let out = PathBuf::from(env::var(\"OUT_DIR\").unwrap());\n \u{20} fs::write(out.join(\"generated.rs\"), \"pub const VALUE: u32 = 7;\\n\").unwrap();\n \u{20} println!(\"cargo:rustc-cfg=generated\");\n }\n",
)
.unwrap();
let lib = match generated {
Generated::Cfg => "#[cfg(generated)]\npub fn value() -> u32 { 7 }\n".to_string(),
Generated::Include => {
"include!(concat!(env!(\"OUT_DIR\"), \"/generated.rs\"));\npub fn value() -> u32 { VALUE }\n"
.to_string()
}
Generated::Text => {
"include!(concat!(env!(\"OUT_DIR\"), \"/generated.rs\"));\n\
pub const WHERE: &str = env!(\"OUT_DIR\");\n\
pub fn value() -> u32 { VALUE }\n"
.to_string()
}
};
std::fs::write(directory.join("src/lib.rs"), lib).unwrap();
let status = Command::new(cargo())
.current_dir(directory)
.args(["generate-lockfile", "--offline"])
.status()
.expect("cargo should run");
assert!(status.success());
}
#[test]
fn out_dir_decides_whether_two_checkouts_share() {
for (generated, expect_hits) in [(Generated::Cfg, true), (Generated::Include, false)] {
assert_eq!(
two_checkouts_share(generated, &[]),
expect_hits,
"the default should not share a compilation that reads OUT_DIR"
);
}
}
#[test]
fn sharing_out_dir_crosses_checkouts_only_where_the_artifact_allows_it() {
let sharing = [("MBX_SHARE_OUT_DIR", "1")];
for (generated, expect_hits) in [(Generated::Include, true), (Generated::Text, false)] {
assert_eq!(
two_checkouts_share(generated, &sharing),
expect_hits,
"sharing changed the wrong shape"
);
}
}
fn two_checkouts_share(generated: Generated, settings: &[(&str, &str)]) -> bool {
let store = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_generated_project(first.path(), generated);
write_generated_project(second.path(), generated);
build_with(
first.path(),
store.path(),
&reports.path().join("first.json"),
settings,
);
let (stats, _) = build_with(
second.path(),
store.path(),
&reports.path().join("second.json"),
settings,
);
count(&stats, "hits") > 0
}
#[test]
fn an_empty_store_reports_nothing() {
let store = tempfile::tempdir().unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
.args(["cache", "stats"])
.env("MBX_CACHE_DIR", store.path())
.output()
.expect("mbx should run");
assert!(output.status.success());
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("objects: 0"), "unexpected output: {stdout}");
}
#[test]
fn a_build_records_the_checkout_it_ran_in() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
build(
project.path(),
store.path(),
&reports.path().join("cold.json"),
);
let records = store.path().join("actions/checkouts/v1");
assert!(
tree_bytes(&records) > 0,
"the build should record its checkout under {}",
records.display()
);
}
#[test]
fn a_failed_build_records_the_compilations_it_completed() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
write_partially_failing_project(project.path());
let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
.current_dir(project.path())
.args(["build", "build", "--workspace", "--offline"])
.env("MBX_CACHE_DIR", store.path())
.env("MBX_GC_AUTO", "0")
.env_remove("CARGO_TARGET_DIR")
.env_remove("CARGO_INCREMENTAL")
.output()
.expect("mbx should run");
assert!(!output.status.success(), "the bad member should fail");
assert!(
tree_bytes(&store.path().join("actions/task-manifests/v1")) > 0,
"the successful dependency should still be recorded as reachable"
);
}
#[test]
fn a_build_sweeps_the_store_to_its_budget() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
let (_, stderr) = build_with(
project.path(),
store.path(),
&reports.path().join("cold.json"),
&[("MBX_GC_MAX_SIZE", "1"), ("MBX_GC_INTERVAL", "0")],
);
assert!(
stderr.contains("gc: evicted"),
"the sweep should say what it evicted: {stderr}"
);
let stats = mbx(store.path(), &["cache", "stats"]);
assert!(
stats.contains("objects: 0"),
"the store should be swept empty: {stats}"
);
}
#[test]
fn automatic_sweeps_can_be_turned_off() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
let (_, stderr) = build_with(
project.path(),
store.path(),
&reports.path().join("cold.json"),
&[
("MBX_GC_MAX_SIZE", "1"),
("MBX_GC_INTERVAL", "0"),
("MBX_GC_AUTO", "0"),
],
);
assert!(!stderr.contains("gc:"), "no sweep should run: {stderr}");
let stats = mbx(store.path(), &["cache", "stats"]);
assert!(
!stats.contains("objects: 0"),
"the store should be left alone: {stats}"
);
}
#[test]
fn deleting_a_checkout_releases_what_only_it_used() {
let store = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
let surviving = tempfile::tempdir().unwrap();
let deleted = tempfile::tempdir().unwrap();
write_named_project(surviving.path(), "surviving");
write_named_project(deleted.path(), "deleted");
build(
surviving.path(),
store.path(),
&reports.path().join("surviving.json"),
);
let budget = store_bytes(store.path()) + 4096;
build(
deleted.path(),
store.path(),
&reports.path().join("deleted.json"),
);
std::fs::remove_dir_all(deleted.path()).unwrap();
mbx(store.path(), &["gc", "--max-size", &budget.to_string()]);
std::fs::remove_dir_all(surviving.path().join("target")).unwrap();
let warm = build(
surviving.path(),
store.path(),
&reports.path().join("warm.json"),
);
assert!(
count(&warm, "hits") > 0,
"the surviving checkout should still be warm: {warm}"
);
}
#[cfg(unix)]
mod target_views {
use super::*;
fn managed(project: &Path) -> std::path::PathBuf {
std::fs::read_link(project.join("target")).expect("target should be a link")
}
#[test]
fn a_managed_target_directory_keeps_the_workspace_clean() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
build_with(
project.path(),
store.path(),
&reports.path().join("cold.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
let directory = managed(project.path());
assert!(
directory.starts_with(store.path().join("targets")),
"outputs should land under the managed root, not the workspace: {}",
directory.display()
);
assert!(
project.path().join("target/debug/libfixture.rlib").exists(),
"the workspace should still reach its own build outputs"
);
}
#[test]
fn a_managed_target_directory_still_hits_the_cache() {
let store = tempfile::tempdir().unwrap();
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(first.path());
write_project(second.path());
build(
first.path(),
store.path(),
&reports.path().join("first.json"),
);
let (warm, _) = build_with(
second.path(),
store.path(),
&reports.path().join("second.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
assert!(
count(&warm, "hits") > 0,
"a relocated target directory should still reuse the first build: {warm}"
);
}
#[test]
fn deleting_a_checkout_frees_its_managed_target_directory() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
build_with(
project.path(),
store.path(),
&reports.path().join("cold.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
let directory = managed(project.path());
assert!(tree_bytes(&directory) > 0);
std::fs::remove_dir_all(project.path()).unwrap();
let output = mbx(store.path(), &["gc", "--max-size", "20GiB"]);
assert!(
!directory.exists(),
"the target directory of a checkout that is gone should be freed"
);
assert!(
output.contains("freed 1 target directories"),
"gc should say what it freed: {output}"
);
}
#[test]
fn a_store_sweep_failure_still_frees_managed_target_directories() {
let store = tempfile::tempdir().unwrap();
let gone = tempfile::tempdir().unwrap();
let current = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_named_project(gone.path(), "gone");
write_named_project(current.path(), "current");
build_with(
gone.path(),
store.path(),
&reports.path().join("gone.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
let directory = managed(gone.path());
std::fs::remove_dir_all(gone.path()).unwrap();
let cas = store.path().join("actions/cas/v1");
std::fs::remove_dir_all(&cas).unwrap();
std::fs::write(&cas, b"not a directory").unwrap();
let (_, stderr) = build_with(
current.path(),
store.path(),
&reports.path().join("current.json"),
&[("MBX_TARGET_VIEWS", "1"), ("MBX_GC_INTERVAL", "0")],
);
assert!(stderr.contains("the store was not swept"), "{stderr}");
assert!(
!directory.exists(),
"target collection should still run when store collection fails"
);
}
#[test]
fn explicit_gc_still_frees_targets_when_store_collection_fails() {
let store = tempfile::tempdir().unwrap();
let gone = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(gone.path());
build_with(
gone.path(),
store.path(),
&reports.path().join("gone.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
let directory = managed(gone.path());
std::fs::remove_dir_all(gone.path()).unwrap();
let cas = store.path().join("actions/cas/v1");
std::fs::remove_dir_all(&cas).unwrap();
std::fs::write(&cas, b"not a directory").unwrap();
let output = Command::new(env!("CARGO_BIN_EXE_mbx"))
.args(["gc", "--max-size", "20GiB"])
.env("MBX_CACHE_DIR", store.path())
.output()
.expect("mbx should run");
assert!(
!output.status.success(),
"the broken store should be reported"
);
assert!(
String::from_utf8_lossy(&output.stdout).contains("freed 1 target directories"),
"successful target collection should still be reported"
);
assert!(
!directory.exists(),
"explicit gc should collect targets independently of the store"
);
}
#[test]
fn a_real_target_directory_is_never_displaced() {
let store = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let reports = tempfile::tempdir().unwrap();
write_project(project.path());
build(
project.path(),
store.path(),
&reports.path().join("cold.json"),
);
assert!(project.path().join("target").is_dir());
build_with(
project.path(),
store.path(),
&reports.path().join("warm.json"),
&[("MBX_TARGET_VIEWS", "1")],
);
assert!(
std::fs::read_link(project.path().join("target")).is_err(),
"existing build outputs are not ours to move"
);
assert!(project.path().join("target/debug/libfixture.rlib").exists());
}
}