use crate::config::{CliSettings, Config, RetentionSettings};
use crate::session::{self, CacheSession};
#[cfg(test)]
use crate::util::workspace_root;
use crate::{policy, store, target};
use bytesize::ByteSize;
use eyre::{Context, Result};
use mbx_cache_core::AgentStats;
use std::collections::BTreeMap;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};
#[derive(usage::Cli)]
#[usage(
bin = "mbx",
version,
config = crate::config::RawConfig,
about = "A build cache for Rust projects",
long_about = "Put mbx in front of any Cargo command. The subcommand and all of its arguments are passed through unchanged, including Cargo aliases and installed subcommands, while compiled work is shared across every checkout and build storage prunes itself. mbx's own subcommands, such as `cache` and `gc`, are reserved.\n\nExamples:\n mbx build --release\n mbx test --workspace\n mbx clippy --all-targets -- -D warnings\n mbx gc --dry-run",
unknown_flags = "error"
)]
struct Cli {
#[usage(sigil = "+", value_name = "TOOLCHAIN")]
toolchain: Option<String>,
#[usage(subcommand)]
command: Commands,
}
#[derive(usage::Subcommands)]
enum Commands {
Doctor(JsonArgs),
Explain(ExplainArgs),
#[usage(hide)]
Setup(SetupArgs),
Gc(GcArgs),
Cache(CacheArgs),
Tui(TuiArgs),
Prefetch(PrefetchArgs),
Exec(ExecArgs),
#[usage(external_subcommand)]
Cargo(Vec<String>),
}
#[derive(usage::Args)]
struct ExecArgs {
#[usage(long, value_name = "DIR")]
project_root: Option<String>,
#[usage(value_name = "COMMAND", required = true, double_dash = "automatic")]
command: Vec<String>,
}
#[derive(usage::Args)]
struct TuiArgs {
#[usage(long)]
once: bool,
}
#[derive(usage::Args)]
#[usage(unknown_flags = "value", dont_delimit_trailing_values = true)]
struct PrefetchArgs {
#[usage(value_name = "CARGO_ARGS", required = true)]
cargo_args: Vec<String>,
}
#[derive(usage::Args)]
struct SetupArgs {
#[usage(long)]
status: bool,
#[usage(long)]
update: bool,
#[usage(long)]
uninstall: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SetupAction {
Install,
Status,
Update,
Uninstall,
}
impl SetupArgs {
fn action(&self) -> Result<SetupAction> {
let selected = self.status as u8 + self.update as u8 + self.uninstall as u8;
if selected > 1 {
eyre::bail!("--status, --update, and --uninstall are mutually exclusive");
}
Ok(if self.status {
SetupAction::Status
} else if self.update {
SetupAction::Update
} else if self.uninstall {
SetupAction::Uninstall
} else {
SetupAction::Install
})
}
}
#[derive(usage::Args)]
struct GcArgs {
#[usage(long, value_name = "SIZE")]
max_size: Option<ByteSize>,
#[usage(long)]
json: bool,
#[usage(long)]
dry_run: bool,
}
#[derive(usage::Args)]
struct JsonArgs {
#[usage(long)]
json: bool,
}
#[derive(usage::Args)]
#[usage(unknown_flags = "value")]
struct ExplainArgs {
#[usage(value_name = "CARGO_COMMAND")]
cargo_command: String,
#[usage(double_dash = "preserve", value_name = "CARGO_ARGS")]
cargo_args: Vec<String>,
}
impl ExplainArgs {
fn arguments(self) -> Vec<String> {
std::iter::once(self.cargo_command)
.chain(self.cargo_args)
.collect()
}
}
#[derive(usage::Args)]
struct CacheArgs {
#[usage(subcommand)]
command: CacheCommands,
}
#[derive(usage::Subcommands)]
enum CacheCommands {
Dir(JsonArgs),
Stats(JsonArgs),
Projects,
Largest(LargestArgs),
Verify,
Remove(RemoveCacheArgs),
}
#[derive(usage::Args)]
struct LargestArgs {
#[usage(long, default = "20")]
limit: usize,
}
#[derive(usage::Args)]
struct RemoveCacheArgs {
workspace: PathBuf,
}
fn with_toolchain(toolchain: Option<&str>, arguments: Vec<String>) -> Vec<String> {
let Some(toolchain) = toolchain else {
return arguments;
};
std::iter::once(format!("+{toolchain}"))
.chain(arguments)
.collect()
}
fn compiles_nothing(command: &Commands) -> Option<&'static str> {
match command {
Commands::Setup(_) => Some("setup"),
Commands::Gc(_) => Some("gc"),
Commands::Cache(_) => Some("cache"),
Commands::Tui(_) => Some("tui"),
Commands::Exec(_) => Some("exec"),
Commands::Doctor(_) | Commands::Explain(_) | Commands::Prefetch(_) | Commands::Cargo(_) => {
None
}
}
}
pub fn run() -> Result<ExitCode> {
let original = std::env::args_os().collect::<Vec<_>>();
let mut cli = Cli::parse();
let toolchain = cli.toolchain.take();
let toolchain = toolchain.as_deref();
if let Some(toolchain) = toolchain
&& let Some(command) = compiles_nothing(&cli.command)
{
eyre::bail!(
"+{toolchain} names a toolchain to compile with, and `mbx {command}` compiles nothing"
);
}
if let Commands::Prefetch(args) = &mut cli.command {
args.cargo_args = with_toolchain(toolchain, original_prefetch_arguments(&original)?);
}
if let Commands::Exec(args) = &mut cli.command {
args.command = original_exec_arguments(&original)?;
}
if let Commands::Doctor(args) = &cli.command {
return crate::doctor::run_loaded(Config::load(), args.json, toolchain);
}
let (config, settings) = Config::load_for_cli()?;
match cli.command {
Commands::Doctor(_) => unreachable!("doctor was handled before configuration loading"),
Commands::Explain(args) => crate::explain::run_with_settings(
&config,
&settings,
&with_toolchain(toolchain, args.arguments()),
),
Commands::Setup(args) => setup(args.action()?),
Commands::Gc(args) => gc(
&config,
args.max_size
.map_or(config.gc.max_bytes, |requested| requested.as_u64()),
args.dry_run,
args.json,
&settings.retention,
)
.map(|()| ExitCode::SUCCESS),
Commands::Cache(args) => match args.command {
CacheCommands::Dir(args) => {
if args.json {
print_json(&CacheDirReport {
version: 1,
store: config.store_dir().display().to_string(),
})?;
} else {
println!("{}", config.store_dir().display());
}
Ok(ExitCode::SUCCESS)
}
CacheCommands::Stats(args) => {
cache_stats(&config, args.json).map(|()| ExitCode::SUCCESS)
}
CacheCommands::Projects => cache_projects(&config).map(|()| ExitCode::SUCCESS),
CacheCommands::Largest(args) => {
cache_largest(&config, args.limit).map(|()| ExitCode::SUCCESS)
}
CacheCommands::Verify => cache_verify(&config),
CacheCommands::Remove(args) => {
cache_remove(&config, &args.workspace).map(|()| ExitCode::SUCCESS)
}
},
Commands::Tui(args) => crate::tui::run(&config, args.once),
Commands::Prefetch(args) => prefetch(&config, &args.cargo_args),
Commands::Exec(args) => exec(&config, &settings, &args),
Commands::Cargo(arguments) => {
cargo(&config, &settings, &with_toolchain(toolchain, arguments))
}
}
}
fn original_exec_arguments(arguments: &[std::ffi::OsString]) -> Result<Vec<String>> {
let Some(index) = arguments
.iter()
.position(|argument| argument == std::ffi::OsStr::new("exec"))
else {
eyre::bail!("could not recover exec arguments");
};
let mut rest = &arguments[index + 1..];
while let Some(first) = rest.first().and_then(|argument| argument.to_str()) {
if first == "--project-root" {
rest = rest.get(2..).unwrap_or_default();
} else if first.starts_with("--project-root=") {
rest = &rest[1..];
} else {
break;
}
}
if rest.first().is_some_and(|argument| argument == "--") {
rest = &rest[1..];
}
strings(rest)
}
fn original_prefetch_arguments(arguments: &[std::ffi::OsString]) -> Result<Vec<String>> {
let Some(index) = arguments
.iter()
.position(|argument| argument == std::ffi::OsStr::new("prefetch"))
else {
eyre::bail!("could not recover prefetch arguments");
};
strings(&arguments[index + 1..])
}
fn strings(arguments: &[std::ffi::OsString]) -> Result<Vec<String>> {
arguments
.iter()
.map(|argument| {
argument
.to_str()
.map(str::to_owned)
.ok_or_else(|| eyre::eyre!("Cargo arguments must be valid UTF-8"))
})
.collect()
}
fn prefetch(config: &Config, arguments: &[String]) -> Result<ExitCode> {
validate_prefetch_config(config)?;
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let working_dir = std::env::current_dir()?;
let roots = resolve_roots(&cargo, arguments, &working_dir);
let session_dir = tempfile::Builder::new().prefix("mbx-prefetch-").tempdir()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let stats = runtime.block_on(async {
let session = CacheSession::start(session_dir.path(), config).await?;
session.prefetch(&roots.workspace_root, arguments).await?;
session.finish().await
})?;
if stats.prefetch_runs == 0 {
println!("no recorded actions for this workspace and Cargo command");
} else {
println!(
"prefetched {} actions; {} downloaded and {} stored locally",
stats.prefetched_actions,
ByteSize::b(stats.downloaded_bytes).display().iec(),
ByteSize::b(stats.stored_bytes).display().iec()
);
}
Ok(ExitCode::SUCCESS)
}
fn validate_prefetch_config(config: &Config) -> Result<()> {
if config.remote.url.is_none() {
eyre::bail!("remote prefetch requires remote.url or MBX_REMOTE_URL");
}
if !config.remote.mode.reads() {
eyre::bail!("remote prefetch requires a read-capable remote.mode");
}
Ok(())
}
fn setup(action: SetupAction) -> Result<ExitCode> {
let executable = std::env::current_exe().wrap_err("failed to locate the mbx executable")?;
let (install_dir, config_path) = setup_paths()?;
setup_at_action(&executable, &install_dir, &config_path, action)
}
fn setup_paths() -> Result<(PathBuf, PathBuf)> {
let data = dirs::data_local_dir()
.ok_or_else(|| eyre::eyre!("the platform data directory could not be located"))?;
let install_dir = data.join("mbx").join("bin");
let cargo_home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
.ok_or_else(|| eyre::eyre!("Cargo's configuration directory could not be located"))?;
let config_path =
if cargo_home.join("config.toml").exists() || !cargo_home.join("config").exists() {
cargo_home.join("config.toml")
} else {
cargo_home.join("config")
};
Ok((install_dir, config_path))
}
#[cfg(test)]
fn setup_at(executable: &Path, install_dir: &Path, config_path: &Path) -> Result<()> {
setup_at_action(executable, install_dir, config_path, SetupAction::Install)?;
Ok(())
}
fn setup_at_action(
executable: &Path,
install_dir: &Path,
config_path: &Path,
action: SetupAction,
) -> Result<ExitCode> {
let contents = match std::fs::read_to_string(config_path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(error.into()),
};
let mut document = contents
.parse::<toml_edit::DocumentMut>()
.wrap_err_with(|| format!("failed to parse {}", config_path.display()))?;
let shim_name = if cfg!(windows) {
format!("{}.exe", crate::session::RUSTC_SHIM_STEM)
} else {
crate::session::RUSTC_SHIM_STEM.into()
};
let shim = install_dir.join(shim_name);
let configured = document
.get("build")
.and_then(toml_edit::Item::as_table_like)
.and_then(|build| build.get("rustc-wrapper"));
let owns_configuration = configured
.and_then(toml_edit::Item::as_str)
.is_some_and(|configured| Path::new(configured) == shim);
match action {
SetupAction::Status => {
if !owns_configuration {
if let Some(configured) = configured {
println!(
"mbx setup is not active: build.rustc-wrapper is {}",
configured
);
} else {
println!("mbx setup is not installed");
}
return Ok(ExitCode::FAILURE);
}
if !shim.is_file() {
println!(
"mbx setup is configured but {} is missing; run `mbx setup --update`",
shim.display()
);
return Ok(ExitCode::FAILURE);
}
if same_file_contents(executable, &shim)? {
println!("mbx setup is installed and current: {}", shim.display());
return Ok(ExitCode::SUCCESS);
}
println!("mbx setup is installed but outdated; run `mbx setup --update`");
return Ok(ExitCode::FAILURE);
}
SetupAction::Update if !owns_configuration => {
if let Some(configured) = configured {
eyre::bail!(
"mbx setup is not active because build.rustc-wrapper is {configured}; refusing to replace another wrapper"
);
}
eyre::bail!("mbx setup is not installed; run `mbx setup` first");
}
SetupAction::Uninstall => {
if owns_configuration {
let build = document
.get_mut("build")
.and_then(toml_edit::Item::as_table_like_mut)
.expect("the configuration was inspected above");
build.remove("rustc-wrapper");
if build.is_empty() {
document.remove("build");
}
crate::util::write_atomic(config_path, document.to_string().as_bytes())?;
}
match std::fs::remove_file(&shim) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
if owns_configuration {
println!(
"removed mbx setup from {} and deleted {}",
config_path.display(),
shim.display()
);
} else {
println!("mbx setup was not installed");
}
return Ok(ExitCode::SUCCESS);
}
SetupAction::Install | SetupAction::Update => {}
}
if let Some(configured) = configured {
if owns_configuration {
std::fs::create_dir_all(install_dir)?;
crate::session::install_shim(
executable,
install_dir,
crate::session::ShimLink::Pinned,
)?;
let verb = if action == SetupAction::Update {
"updated"
} else {
"refreshed"
};
println!("{verb} {}; Cargo was already configured", shim.display());
return Ok(ExitCode::SUCCESS);
}
println!(
"left {} unchanged: build.rustc-wrapper is already {}",
config_path.display(),
configured
);
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(install_dir)?;
let shim =
crate::session::install_shim(executable, install_dir, crate::session::ShimLink::Pinned)?;
document["build"]["rustc-wrapper"] = toml_edit::value(shim.to_string_lossy().into_owned());
let parent = config_path
.parent()
.ok_or_else(|| eyre::eyre!("Cargo configuration path has no parent"))?;
std::fs::create_dir_all(parent)?;
crate::util::write_atomic(config_path, document.to_string().as_bytes())?;
println!(
"installed {} and configured {}",
shim.display(),
config_path.display()
);
println!("plain cargo commands now use mbx's local action cache");
Ok(ExitCode::SUCCESS)
}
fn same_file_contents(left: &Path, right: &Path) -> Result<bool> {
let left_metadata = std::fs::metadata(left)?;
let right_metadata = std::fs::metadata(right)?;
if left_metadata.len() != right_metadata.len() {
return Ok(false);
}
Ok(mbx_cache_core::CacheDigest::blake3_file(left)?
== mbx_cache_core::CacheDigest::blake3_file(right)?)
}
fn cargo(config: &Config, settings: &CliSettings, arguments: &[String]) -> Result<ExitCode> {
cargo_with_settings_and_bypass_log(config, settings, arguments, None)
}
pub(crate) fn cargo_with_bypass_log(
config: &Config,
arguments: &[String],
bypass_log: Option<&Path>,
) -> Result<ExitCode> {
cargo_with_settings_and_bypass_log(config, &CliSettings::default(), arguments, bypass_log)
}
pub(crate) fn cargo_with_settings_and_bypass_log(
config: &Config,
settings: &CliSettings,
arguments: &[String],
bypass_log: Option<&Path>,
) -> Result<ExitCode> {
let retention = &settings.retention;
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let cache_links = settings.cache_links && session::cache_links_supported();
if settings.cache_links && !cache_links && std::env::var_os(session::CACHE_LINKS_ENV).is_some()
{
log::warn!("caching native links is not supported on this platform");
}
let working_dir = std::env::current_dir()?;
let mut roots = resolve_roots(&cargo, arguments, &working_dir);
let mut config = config.clone();
config.apply_workspace_policy(&roots.workspace_root)?;
let incremental = policy::incremental_allowed(config.incremental);
if config.incremental && !incremental {
log::warn!(
"incremental compilation is disabled here; it needs an earlier build to build on"
);
}
if incremental && std::env::var("CARGO_INCREMENTAL").as_deref() == Ok("0") {
log::warn!(
"CARGO_INCREMENTAL=0 is set in the environment, so this build is not incremental after all"
);
}
config.incremental = incremental;
let learned_incremental = policy::learned_incremental_allowed(settings.learned_incremental)
&& !incremental
&& !config.verify;
let config = &config;
let migrate_existing = prompt_to_manage_existing_target(config, &roots, arguments)?;
let (placed, removed_target_bytes) = if migrate_existing {
let outcome = target::migrate_existing(
config,
&roots.workspace_root,
&roots.target_dir,
roots.target_dir_requested,
)?;
(outcome.managed, outcome.removed_bytes)
} else {
(
target::place(
config,
&roots.workspace_root,
&roots.target_dir,
roots.target_dir_requested,
),
None,
)
};
if let Some(bytes) = removed_target_bytes {
crate::session::note(&format!(
"mbx[gc]: freed {} by removing the existing target/ directory",
ByteSize::b(bytes).display().iec()
));
}
if let Some(directory) = &placed {
roots.target_dir = directory.clone();
} else {
target::touch_managed(config, &roots.workspace_root, &roots.target_dir);
}
if !cargo_help_requested(arguments) && !was_explained(&config.store_dir()) {
let reflinks = crate::util::reflinks_work(&config.cache_dir, &roots.target_dir);
crate::session::note(&first_run_notice(config, retention, reflinks));
mark_explained(&config.store_dir());
}
let session_dir = tempfile::Builder::new().prefix("mbx-session-").tempdir()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let session_outcome = runtime.block_on(async {
let session = CacheSession::start(session_dir.path(), config).await?;
let mut environment = inherited_environment(|name| std::env::var(name).ok(), &working_dir);
if let Some(path) = bypass_log {
let path = if path.is_absolute() {
path.to_path_buf()
} else {
working_dir.join(path)
};
environment.insert(
crate::session::BYPASS_LOG_ENV.into(),
path.display().to_string(),
);
}
if let Some(directory) = &placed {
environment.insert(
CARGO_TARGET_DIR_ENV.into(),
directory.to_string_lossy().into_owned(),
);
}
let run = session
.begin(
&roots.workspace_root,
&roots.target_dir,
arguments,
&mut environment,
)
.await;
environment.insert(
session::LEARNED_INCREMENTAL_ENV.into(),
if learned_incremental { "1" } else { "0" }.into(),
);
environment.insert(
session::CACHE_LINKS_ENV.into(),
if cache_links { "1" } else { "0" }.into(),
);
let status = run_cargo(&cargo, arguments, environment);
if let Some(run) = run
&& let Err(error) = run.commit().await
{
log::warn!("the build manifest was not committed: {error}");
}
let stats = match session.finish().await {
Ok(stats) => {
crate::session::display_stats(&stats, config);
Some(stats)
}
Err(error) => {
log::warn!("the cache session did not shut down cleanly: {error}");
None
}
};
Ok((status, stats))
});
account_session(config, settings, session_outcome, removed_target_bytes)
}
fn account_session(
config: &Config,
settings: &CliSettings,
session_outcome: Result<(Result<ExitCode>, Option<AgentStats>)>,
removed_target_bytes: Option<u64>,
) -> Result<ExitCode> {
let retention = &settings.retention;
let (status, stats) = match session_outcome {
Ok((status, stats)) => (status, stats),
Err(error) => (Err(error), None),
};
let mut delta = sweep_store(config, retention);
delta.freed_requested_bytes = removed_target_bytes.unwrap_or_default();
let facts = stats
.as_ref()
.map_or_else(crate::savings::SessionFacts::default, |stats| {
crate::savings::SessionFacts {
hits: stats.hits,
avoided_compiler_ns: stats.avoided_compiler_duration_ns,
}
});
if let Some(stats) = &stats {
if crate::session::session_was_active(stats) {
delta.builds = 1;
}
delta.cached_compilations = stats.hits;
delta.avoided_compiler_ns = stats.avoided_compiler_duration_ns;
delta.reflinked_bytes = stats.reflinked_output_bytes;
}
if let Some(line) =
crate::savings::record_and_describe(&config.store_dir(), &delta, &facts, settings.savings)
{
crate::session::note(&line);
}
status
}
fn exec(config: &Config, settings: &CliSettings, args: &ExecArgs) -> Result<ExitCode> {
let Some((program, arguments)) = args.command.split_first() else {
eyre::bail!("exec needs a command to run");
};
let program: std::ffi::OsString = program.into();
let working_dir = std::env::current_dir()?;
let project_root = match &args.project_root {
Some(root) => absolute(&working_dir, root),
None => discover_project_root(&working_dir),
};
let mut config = config.clone();
config.apply_workspace_policy(&project_root)?;
let config = &config;
if !config.cc {
log::warn!("the C and C++ cache is disabled, so this command is not cached");
return run_cargo(&program, arguments, BTreeMap::new());
}
let session_dir = tempfile::Builder::new().prefix("mbx-session-").tempdir()?;
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let session_outcome = runtime.block_on(async {
let Some(shims) = session::install_path_shims(&config.cache_dir.join("shims"))? else {
log::warn!("no C or C++ compiler was found on PATH, so this command is not cached");
return Ok((run_cargo(&program, arguments, BTreeMap::new()), None));
};
let session = CacheSession::start(session_dir.path(), config).await?;
let mut environment = inherited_environment(|name| std::env::var(name).ok(), &working_dir);
let run = session
.begin_exec(&project_root, &args.command, &shims, &mut environment)
.await;
let status = run_cargo(&program, arguments, environment);
if let Some(run) = run
&& let Err(error) = run.commit().await
{
log::warn!("the build manifest was not committed: {error}");
}
let stats = match session.finish().await {
Ok(stats) => {
crate::session::display_stats(&stats, config);
Some(stats)
}
Err(error) => {
log::warn!("the cache session did not shut down cleanly: {error}");
None
}
};
Ok((status, stats))
});
account_session(config, settings, session_outcome, None)
}
fn discover_project_root(working_dir: &Path) -> PathBuf {
let mut directory = working_dir;
loop {
if directory.join(".git").exists() {
return directory.to_path_buf();
}
match directory.parent() {
Some(parent) => directory = parent,
None => return working_dir.to_path_buf(),
}
}
}
const NOTICE_STAMP: &str = "notice/v1/explained";
fn was_explained(store: &Path) -> bool {
store.join(NOTICE_STAMP).exists()
}
fn mark_explained(store: &Path) {
if let Err(error) = crate::util::write_atomic(&store.join(NOTICE_STAMP), b"") {
log::debug!("the first-run notice was not recorded: {error}");
}
}
fn first_run_notice(config: &Config, retention: &RetentionSettings, reflinks: bool) -> String {
let mut lines = vec!["mbx[setup]: first build on this machine".to_string()];
if config.gc.auto {
lines.push(format!(
"mbx[setup]: cache is {}, shared by every checkout and worktree, pruned to {}",
config.cache_dir.display(),
ByteSize::b(config.gc.max_bytes).display().iec(),
));
} else {
lines.push(format!(
"mbx[setup]: cache is {}, shared by every checkout and worktree; automatic collection is off, so only `mbx gc` reclaims it",
config.cache_dir.display(),
));
}
if reflinks {
lines.push(
"mbx[setup]: this filesystem supports reflinks, so target/ shares disk with the cache instead of copying"
.to_string(),
);
}
if config.target.views && config.gc.auto {
let mut reasons = vec!["its checkout is gone".to_string()];
if let Some(age) = retention.target_max_age {
reasons.push(format!("unused for {}", crate::util::format_span(age)));
}
if let Some(bytes) = retention.target_max_bytes {
reasons.push(format!("over {} total", ByteSize::b(bytes).display().iec()));
}
lines.push(format!(
"mbx[setup]: target/ is managed: deleted when {}",
join_clauses(&reasons),
));
}
lines.push(
"mbx[setup]: `mbx gc --dry-run` previews cleanup; every limit is configurable"
.to_string(),
);
lines.join("\n")
}
fn join_clauses(clauses: &[String]) -> String {
match clauses {
[] => String::new(),
[only] => only.clone(),
[first, second] => format!("{first} or {second}"),
[rest @ .., last] => format!("{}, or {last}", rest.join(", ")),
}
}
fn prompt_to_manage_existing_target(
config: &Config,
roots: &Roots,
arguments: &[String],
) -> Result<bool> {
if cargo_help_requested(arguments)
|| !std::io::stdin().is_terminal()
|| !std::io::stderr().is_terminal()
{
return Ok(false);
}
prompt_to_manage_existing_target_with(config, roots, |directory| {
let description = format!(
"mbx can remove {} and replace it with a managed target that is pruned after this checkout is deleted.",
directory.display()
);
match demand::Confirm::new("Use a managed target directory?")
.description(&description)
.affirmative("Remove target/")
.negative("Keep it")
.selected(false)
.run()
{
Ok(answer) => Ok(answer),
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => Ok(false),
Err(error) => Err(error.into()),
}
})
}
fn cargo_help_requested(arguments: &[String]) -> bool {
arguments.first().is_some_and(|argument| argument == "help")
|| arguments
.iter()
.any(|argument| argument == "--help" || argument == "-h")
}
fn prompt_to_manage_existing_target_with(
config: &Config,
roots: &Roots,
prompt: impl FnOnce(&Path) -> Result<bool>,
) -> Result<bool> {
if !target::can_remove_existing(
config,
&roots.workspace_root,
&roots.target_dir,
roots.target_dir_requested,
) {
return Ok(false);
}
prompt(&roots.target_dir)
}
fn run_cargo(
cargo: &std::ffi::OsStr,
arguments: &[String],
environment: BTreeMap<String, String>,
) -> Result<ExitCode> {
let mut command = Command::new(cargo);
command.args(arguments);
command.envs(environment);
let status = command
.status()
.wrap_err_with(|| format!("failed to run {}", cargo.to_string_lossy()))?;
Ok(exit_code(status))
}
#[cfg(unix)]
fn exit_code(status: std::process::ExitStatus) -> ExitCode {
use std::os::unix::process::ExitStatusExt as _;
match (status.code(), status.signal()) {
(Some(code), _) => ExitCode::from(code as u8),
(None, Some(signal)) => ExitCode::from(128u8.saturating_add(signal as u8)),
(None, None) => ExitCode::FAILURE,
}
}
#[cfg(not(unix))]
fn exit_code(status: std::process::ExitStatus) -> ExitCode {
match status.code() {
Some(code) => ExitCode::from(code as u8),
None => ExitCode::FAILURE,
}
}
fn gc(
config: &Config,
max_bytes: u64,
dry_run: bool,
json: bool,
retention: &RetentionSettings,
) -> Result<()> {
let store = config.store_dir();
let target_budget = target_budget(retention, max_bytes);
let pruned = target::collect(
&config.target.root,
target_budget,
retention.target_max_age,
dry_run,
);
let projected_target_bytes = match &pruned {
Ok(outcome) => outcome.remaining_bytes,
Err(_) if retention.max_total_bytes.is_some() => target::stats(&config.target.root)
.map(|stats| stats.bytes)
.unwrap_or_default(),
Err(_) => 0,
};
let store_budget = retention.max_total_bytes.map_or(max_bytes, |total| {
max_bytes.min(total.saturating_sub(projected_target_bytes))
});
if !dry_run {
crate::scheduler::prune_flights(&config.cache_dir);
}
let outcome = if dry_run {
store::gc_dry_run(&store, store_budget)
} else {
store::gc(&store, store_budget)
};
let outcome = match outcome {
Ok(outcome) => outcome,
Err(error) => {
match pruned {
Ok(pruned) => {
record_collection(&store, 0, pruned.removed_bytes, dry_run);
if !json && pruned.removed_views > 0 {
println!("{}", target_removals(&pruned, dry_run));
}
}
Err(prune_error) => {
log::warn!("target directories were not collected: {prune_error}");
}
}
return Err(error);
}
};
record_collection(
&store,
outcome.removed_bytes,
pruned.as_ref().map_or(0, |pruned| pruned.removed_bytes),
dry_run,
);
if json {
let pruned = pruned?;
print_json(&GcReport {
version: 1,
max_bytes,
dry_run,
action_store: GcActionStoreReport {
removed_objects: outcome.removed_objects,
removed_action_results: outcome.removed_action_results,
removed_checkout_records: outcome.removed_checkout_records,
removed_session_streams: outcome.removed_session_streams,
removed_bytes: outcome.removed_bytes,
remaining_bytes: outcome.remaining_bytes,
},
targets: GcTargetReport {
removed_directories: pruned.removed_views,
removed_bytes: pruned.removed_bytes,
},
})?;
} else {
print_gc_store_outcome(&outcome, dry_run);
let pruned = pruned?;
if pruned.removed_views > 0 {
println!("{}", target_removals(&pruned, dry_run));
}
}
Ok(())
}
fn record_collection(store: &Path, store_bytes: u64, target_bytes: u64, dry_run: bool) {
if dry_run || (store_bytes == 0 && target_bytes == 0) {
return;
}
crate::savings::record_quietly(
store,
&crate::savings::Delta {
freed_store_bytes: store_bytes,
freed_target_bytes: target_bytes,
..crate::savings::Delta::default()
},
);
}
fn print_gc_store_outcome(outcome: &store::GcOutcome, dry_run: bool) {
let prefix = if dry_run { "would have " } else { "" };
println!("{prefix}{}", evictions(outcome));
if outcome.removed_checkout_records > 0 {
println!(
"{prefix}dropped {} stale checkout records",
outcome.removed_checkout_records
);
}
if outcome.removed_session_streams > 0 {
println!(
"{prefix}dropped {} session event streams",
outcome.removed_session_streams
);
}
}
fn target_removals(outcome: &target::CollectionOutcome, dry_run: bool) -> String {
let verb = if dry_run { "would free" } else { "freed" };
format!(
"{verb} {} target directories ({}, {} abandoned and {} live); {} remain",
outcome.removed_views,
ByteSize::b(outcome.removed_bytes).display().iec(),
outcome.removed_stale_views,
outcome.removed_live_views,
ByteSize::b(outcome.remaining_bytes).display().iec(),
)
}
fn evictions(outcome: &store::GcOutcome) -> String {
format!(
"evicted {} objects and {} action results ({}); {} remain",
outcome.removed_objects,
outcome.removed_action_results,
ByteSize::b(outcome.removed_bytes).display().iec(),
ByteSize::b(outcome.remaining_bytes).display().iec(),
)
}
fn sweep_store(config: &Config, retention: &RetentionSettings) -> crate::savings::Delta {
let mut delta = crate::savings::Delta::default();
if !config.gc.auto {
return delta;
}
match store::claim_sweep(&config.store_dir(), config.gc.interval) {
Ok(false) => {}
Ok(true) => {
let pruned = prune_targets(config, retention, config.gc.max_bytes);
delta.freed_target_bytes = pruned.freed_bytes;
let store_budget = retention
.max_total_bytes
.map_or(config.gc.max_bytes, |total| {
let target_bytes = pruned.remaining_bytes.unwrap_or_else(|| {
target::stats(&config.target.root)
.map(|stats| stats.bytes)
.unwrap_or_default()
});
config.gc.max_bytes.min(total.saturating_sub(target_bytes))
});
crate::scheduler::prune_flights(&config.cache_dir);
let outcome = match store::gc(&config.store_dir(), store_budget) {
Ok(outcome) => outcome,
Err(error) => {
log::warn!("the store was not swept: {error}");
return delta;
}
};
delta.freed_store_bytes = outcome.removed_bytes;
if outcome.removed_bytes > 0 {
crate::session::note(&format!("mbx[gc]: {}", evictions(&outcome)));
}
}
Err(error) => {
log::warn!("the store was not swept: {error}");
delta.freed_target_bytes =
prune_targets(config, retention, config.gc.max_bytes).freed_bytes;
}
}
delta
}
struct PruneReport {
remaining_bytes: Option<u64>,
freed_bytes: u64,
}
fn prune_targets(
config: &Config,
retention: &RetentionSettings,
store_reserve: u64,
) -> PruneReport {
let target_budget = target_budget(retention, store_reserve);
match target::collect(
&config.target.root,
target_budget,
retention.target_max_age,
false,
) {
Ok(pruned) => {
if pruned.removed_views > 0 {
crate::session::note(&format!("mbx[gc]: {}", target_removals(&pruned, false)));
}
PruneReport {
remaining_bytes: Some(pruned.remaining_bytes),
freed_bytes: pruned.removed_bytes,
}
}
Err(error) => {
log::warn!("target directories were not collected: {error}");
PruneReport {
remaining_bytes: None,
freed_bytes: 0,
}
}
}
}
fn target_budget(retention: &RetentionSettings, store_reserve: u64) -> Option<u64> {
retention
.max_total_bytes
.map_or(retention.target_max_bytes, |total| {
let combined = total.saturating_sub(store_reserve);
Some(
retention
.target_max_bytes
.map_or(combined, |target| target.min(combined)),
)
})
}
fn cache_stats(config: &Config, json: bool) -> Result<()> {
let store = config.store_dir();
let stats = store::stats(&store)?;
if json {
let views = target::stats(&config.target.root)?;
return print_json(&CacheStatsReport {
version: 1,
store: store.display().to_string(),
objects: stats.objects,
object_bytes: stats.object_bytes,
action_results: stats.action_results,
action_result_bytes: stats.action_result_bytes,
total_bytes: stats.total_bytes(),
live_checkouts: stats.live_checkouts,
stale_checkouts: stats.stale_checkouts,
target_directories: views.views,
target_bytes: views.bytes,
});
}
println!("store: {}", store.display());
println!(
"objects: {} ({})",
stats.objects,
ByteSize::b(stats.object_bytes).display().iec()
);
println!(
"action results: {} ({})",
stats.action_results,
ByteSize::b(stats.action_result_bytes).display().iec()
);
println!(
"total: {}",
ByteSize::b(stats.total_bytes()).display().iec()
);
println!(
"checkouts: {} live, {} stale",
stats.live_checkouts, stats.stale_checkouts
);
let views = target::stats(&config.target.root)?;
println!(
"target directories: {} ({})",
views.views,
ByteSize::b(views.bytes).display().iec()
);
Ok(())
}
#[derive(serde::Serialize)]
struct CacheDirReport {
version: u8,
store: String,
}
#[derive(serde::Serialize)]
struct CacheStatsReport {
version: u8,
store: String,
objects: u64,
object_bytes: u64,
action_results: u64,
action_result_bytes: u64,
total_bytes: u64,
live_checkouts: u64,
stale_checkouts: u64,
target_directories: u64,
target_bytes: u64,
}
#[derive(serde::Serialize)]
struct GcReport {
version: u8,
max_bytes: u64,
dry_run: bool,
action_store: GcActionStoreReport,
targets: GcTargetReport,
}
#[derive(serde::Serialize)]
struct GcActionStoreReport {
removed_objects: u64,
removed_action_results: u64,
removed_checkout_records: u64,
removed_session_streams: u64,
removed_bytes: u64,
remaining_bytes: u64,
}
#[derive(serde::Serialize)]
struct GcTargetReport {
removed_directories: u64,
removed_bytes: u64,
}
fn print_json(value: &impl serde::Serialize) -> Result<()> {
println!("{}", serde_json::to_string_pretty(value)?);
Ok(())
}
fn cache_projects(config: &Config) -> Result<()> {
let projects = store::projects(&config.store_dir())?;
if projects.is_empty() {
println!("no recorded workspaces");
return Ok(());
}
for project in projects {
let state = if project.live { "live" } else { "stale" };
println!(
"{}\t{} actions\t{} targets\t{} identities\t{state}",
project.workspace_root.display(),
ByteSize::b(project.action_bytes).display().iec(),
ByteSize::b(project.target_bytes).display().iec(),
project.identities,
);
}
Ok(())
}
fn cache_largest(config: &Config, limit: usize) -> Result<()> {
for entry in store::largest(&config.store_dir(), limit)? {
let path = entry
.path
.strip_prefix(config.store_dir())
.unwrap_or(&entry.path);
println!(
"{}\t{}\t{}",
ByteSize::b(entry.bytes).display().iec(),
entry.kind,
path.display()
);
}
Ok(())
}
fn cache_verify(config: &Config) -> Result<ExitCode> {
let outcome = store::verify(&config.store_dir())?;
println!(
"verified {} objects and {} action results",
outcome.checked_objects, outcome.checked_action_results
);
for path in &outcome.problems {
println!("invalid: {}", path.display());
}
Ok(if outcome.problems.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
})
}
fn cache_remove(config: &Config, workspace: &Path) -> Result<()> {
let working_dir = std::env::current_dir()?;
let requested = absolute(&working_dir, &workspace.to_string_lossy());
let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
let workspace = cache_workspace_root(&cargo, &requested);
let target_bytes = target::remove_workspace(&config.target.root, &workspace)?;
let removed = store::remove_project(&config.store_dir(), &workspace)?;
println!(
"removed {} checkout records for {}",
removed.removed_checkout_records,
workspace.display()
);
if let Some(bytes) = target_bytes {
crate::savings::record_quietly(
&config.store_dir(),
&crate::savings::Delta {
freed_requested_bytes: bytes,
..crate::savings::Delta::default()
},
);
println!(
"freed managed target directory ({})",
ByteSize::b(bytes).display().iec()
);
}
println!("shared cache objects remain available to other workspaces and normal GC");
Ok(())
}
fn cache_workspace_root(cargo: &std::ffi::OsStr, requested: &Path) -> PathBuf {
let arguments = vec![
"--manifest-path".to_string(),
requested.join("Cargo.toml").to_string_lossy().into_owned(),
];
cargo_roots(cargo, &arguments, None)
.map(|roots| roots.workspace_root)
.unwrap_or_else(|| requested.to_path_buf())
}
#[derive(Debug, PartialEq, Eq)]
struct Roots {
workspace_root: PathBuf,
target_dir: PathBuf,
target_dir_requested: bool,
}
fn inherited_environment(
get_env: impl Fn(&str) -> Option<String>,
working_dir: &Path,
) -> BTreeMap<String, String> {
let mut environment = BTreeMap::new();
if let Some(wrapper) = get_env("RUSTC_WRAPPER").filter(|value| !value.is_empty()) {
environment.insert("RUSTC_WRAPPER".into(), wrapper);
}
if let Some(log) = get_env(crate::session::BYPASS_LOG_ENV).filter(|value| !value.is_empty()) {
environment.insert(
crate::session::BYPASS_LOG_ENV.into(),
absolute(working_dir, &log).display().to_string(),
);
}
environment
}
fn resolve_roots(cargo: &std::ffi::OsStr, arguments: &[String], working_dir: &Path) -> Roots {
resolve_roots_with(
cargo,
arguments,
working_dir,
std::env::var_os(CARGO_TARGET_DIR_ENV),
)
}
const CARGO_TARGET_DIR_ENV: &str = "CARGO_TARGET_DIR";
fn resolve_roots_with(
cargo: &std::ffi::OsStr,
arguments: &[String],
working_dir: &Path,
target_dir_env: Option<std::ffi::OsString>,
) -> Roots {
let resolved = mbx_cache_cargo::resolve(cargo, arguments, working_dir, target_dir_env);
Roots {
workspace_root: resolved.workspace_root,
target_dir: resolved.target_dir,
target_dir_requested: resolved.target_dir_requested,
}
}
fn absolute(working_dir: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
working_dir.join(path)
}
}
const PROBE_GLOBAL_FLAGS: [&str; 3] = ["-C", "--config", "-Z"];
const PROBE_MANIFEST_TOGGLES: [&str; 3] = ["--offline", "--frozen", "--locked"];
#[cfg(test)]
fn cargo_arguments(arguments: &[String]) -> &[String] {
let end = arguments
.iter()
.position(|argument| argument == "--")
.unwrap_or(arguments.len());
&arguments[..end]
}
fn forwarded_flags(arguments: &[String], flags: &[&str]) -> Vec<String> {
let mut forwarded = Vec::new();
let mut remaining = arguments.iter();
while let Some(argument) = remaining.next() {
if let Some((flag, value)) = argument
.split_once('=')
.filter(|(flag, _)| flags.contains(flag))
{
forwarded.push(flag.to_string());
forwarded.push(value.to_string());
} else if flags.contains(&argument.as_str()) {
if let Some(value) = remaining.next() {
forwarded.push(argument.clone());
forwarded.push(value.clone());
}
} else if flags
.iter()
.any(|flag| flag.len() == 2 && argument.len() > 2 && argument.starts_with(flag))
{
forwarded.push(argument.clone());
}
}
forwarded
}
#[cfg(test)]
fn invocation_dir(arguments: &[String], working_dir: &Path) -> PathBuf {
flag_value(arguments, "-C")
.map(|value| absolute(working_dir, value))
.unwrap_or_else(|| working_dir.to_path_buf())
}
fn cargo_roots(
cargo: &std::ffi::OsStr,
arguments: &[String],
target_dir_env: Option<&std::ffi::OsStr>,
) -> Option<Roots> {
let mut command = Command::new(cargo);
match target_dir_env {
Some(dir) => command.env(CARGO_TARGET_DIR_ENV, dir),
None => command.env_remove(CARGO_TARGET_DIR_ENV),
};
command.args(forwarded_flags(arguments, &PROBE_GLOBAL_FLAGS));
command.args(["metadata", "--no-deps", "--format-version", "1"]);
if let Some(manifest) = flag_value(arguments, "--manifest-path") {
command.args(["--manifest-path", manifest]);
}
for toggle in arguments
.iter()
.filter(|argument| PROBE_MANIFEST_TOGGLES.contains(&argument.as_str()))
{
command.arg(toggle);
}
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_cargo_roots(&output.stdout)
}
fn parse_cargo_roots(metadata: &[u8]) -> Option<Roots> {
let metadata: serde_json::Value = serde_json::from_slice(metadata).ok()?;
Some(Roots {
workspace_root: PathBuf::from(metadata.get("workspace_root")?.as_str()?),
target_dir: PathBuf::from(metadata.get("target_directory")?.as_str()?),
target_dir_requested: false,
})
}
#[cfg(test)]
fn target_dir_argument(arguments: &[String]) -> Option<&str> {
flag_value(arguments, "--target-dir")
}
#[cfg(test)]
fn cargo_config_may_set_target_dir(arguments: &[String], invocation_dir: &Path) -> bool {
if std::env::var_os("CARGO_BUILD_TARGET_DIR").is_some_and(|value| !value.is_empty()) {
return true;
}
if config_arguments(arguments).any(|value| {
value
.split_once('=')
.is_none_or(|(key, _)| matches!(key.trim(), "build.target-dir" | "include"))
}) {
return true;
}
let project_configs = invocation_dir.ancestors().flat_map(|directory| {
let cargo = directory.join(".cargo");
[cargo.join("config.toml"), cargo.join("config")]
});
let home_configs = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
.into_iter()
.flat_map(|cargo| [cargo.join("config.toml"), cargo.join("config")]);
project_configs
.chain(home_configs)
.any(|path| cargo_config_file_may_set_target_dir(&path))
}
#[cfg(test)]
fn config_arguments(arguments: &[String]) -> impl Iterator<Item = &str> {
let mut values = Vec::new();
let mut remaining = arguments.iter();
while let Some(argument) = remaining.next() {
if let Some(value) = argument.strip_prefix("--config=") {
values.push(value);
} else if argument == "--config"
&& let Some(value) = remaining.next()
{
values.push(value.as_str());
}
}
values.into_iter()
}
#[cfg(test)]
fn cargo_config_file_may_set_target_dir(path: &Path) -> bool {
let contents = match std::fs::read_to_string(path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false,
Err(_) => return true,
};
let Ok(config) = toml::from_str::<toml::Value>(&contents) else {
return true;
};
config
.get("build")
.and_then(|build| build.get("target-dir"))
.is_some()
|| config.get("include").is_some()
}
fn flag_value<'a>(arguments: &'a [String], flag: &str) -> Option<&'a str> {
let joined = format!("{flag}=");
let mut arguments = arguments.iter();
while let Some(argument) = arguments.next() {
if let Some(value) = argument.strip_prefix(&joined) {
return Some(value);
}
if argument == flag {
return arguments.next().map(String::as_str);
}
}
None
}
#[cfg(test)]
#[path = "cli_tests.rs"]
mod tests;