use crate::config::Config;
use eyre::{Context, Result};
use mbx_cache_core::CacheDigest;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const VIEWS_DIR: &str = "v1";
const VIEW_RECORD_VERSION: u8 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ViewRecord {
version: u8,
workspace_root: PathBuf,
updated_secs: u64,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct ViewStats {
pub views: u64,
pub bytes: u64,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct PruneOutcome {
pub removed_views: u64,
pub removed_bytes: u64,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct CollectionOutcome {
pub removed_views: u64,
pub removed_bytes: u64,
pub removed_stale_views: u64,
pub removed_live_views: u64,
pub remaining_bytes: u64,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MigrationOutcome {
pub managed: Option<PathBuf>,
pub removed_bytes: Option<u64>,
}
pub fn remove_workspace(root: &Path, workspace_root: &Path) -> Result<Option<u64>> {
let record_path = view_record_path(root, workspace_root);
let Some(record) = read_view_record(&record_path) else {
return Ok(None);
};
if record.workspace_root != workspace_root {
return Ok(None);
}
let directory = record_path.with_extension("");
let bytes = tree_bytes(&directory);
match std::fs::remove_dir_all(&directory) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
std::fs::remove_file(&record_path).or_else(|error| {
(error.kind() == std::io::ErrorKind::NotFound)
.then_some(())
.ok_or(error)
})?;
let link = workspace_root.join("target");
if std::fs::read_link(&link).is_ok_and(|destination| destination == directory) {
remove_link(&link)?;
}
Ok(Some(bytes))
}
pub fn can_remove_existing(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
) -> bool {
config.target.views
&& !requested
&& target_dir == workspace_root.join("target")
&& std::fs::symlink_metadata(target_dir).is_ok_and(|metadata| metadata.is_dir())
}
pub fn migrate_existing(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
) -> Result<MigrationOutcome> {
migrate_existing_with(config, workspace_root, target_dir, requested, || {
place(config, workspace_root, target_dir, requested)
})
}
fn migrate_existing_with(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
place_target: impl FnOnce() -> Option<PathBuf>,
) -> Result<MigrationOutcome> {
if !std::fs::symlink_metadata(target_dir).is_ok_and(|metadata| metadata.is_dir()) {
eyre::bail!(
"{} is no longer a real target directory, so it was not migrated",
target_dir.display()
);
}
if !can_remove_existing(config, workspace_root, target_dir, requested) {
eyre::bail!("{} is not eligible for migration", target_dir.display());
}
let backup_root = tempfile::Builder::new()
.prefix(".mbx-target-backup-")
.tempdir_in(workspace_root)
.wrap_err("could not create a temporary target backup")?;
let backup = backup_root.path().join("target");
std::fs::rename(target_dir, &backup)
.wrap_err_with(|| format!("could not temporarily move {}", target_dir.display()))?;
let old_bytes = tree_bytes(&backup);
if let Some(managed) = place_target() {
if let Err(error) = std::fs::remove_dir_all(&backup) {
let retained = backup_root.keep().join("target");
log::warn!(
"the old target directory was retained at {}: {error}",
retained.display()
);
return Ok(MigrationOutcome {
managed: Some(managed),
removed_bytes: None,
});
}
return Ok(MigrationOutcome {
managed: Some(managed),
removed_bytes: Some(old_bytes),
});
}
let managed = view_dir(&config.target.root, workspace_root);
let restore = (|| -> Result<()> {
match std::fs::symlink_metadata(target_dir) {
Ok(metadata)
if metadata.file_type().is_symlink()
&& std::fs::read_link(target_dir).is_ok_and(|link| link == managed) =>
{
remove_link(target_dir).wrap_err("could not remove the failed managed link")?;
}
Ok(_) => eyre::bail!(
"{} was occupied while restoring the old target directory",
target_dir.display()
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).wrap_err("could not inspect the failed migration"),
}
std::fs::rename(&backup, target_dir)
.wrap_err_with(|| format!("could not restore {}", target_dir.display()))
})();
if let Err(error) = restore {
let retained = backup_root.keep().join("target");
return Err(error).wrap_err_with(|| {
format!(
"the old target directory was retained at {}",
retained.display()
)
});
}
Ok(MigrationOutcome::default())
}
pub fn place(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
) -> Option<PathBuf> {
if !config.target.views {
return None;
}
if requested {
log::debug!(
"leaving the target directory at {} where it was asked for",
target_dir.display()
);
return None;
}
if target_dir != workspace_root.join("target") {
log::debug!(
"leaving the target directory at {} where it was asked for",
target_dir.display()
);
return None;
}
let managed = view_dir(&config.target.root, workspace_root);
if !managed.is_absolute() {
log::warn!(
"the managed target directory {} is not absolute, so the target directory was left alone",
managed.display()
);
return None;
}
let link = match link_view(target_dir, &managed, workspace_root) {
Ok(link) => link,
Err(error) => {
log::debug!("{error}");
return None;
}
};
let record_path = view_record_path(&config.target.root, workspace_root);
let previous_record = match read_record_state(&record_path) {
Ok(record) => record,
Err(error) => {
log::warn!("the existing target record could not be preserved: {error}");
if let Err(error) = rollback_link(target_dir, &link) {
log::warn!(
"the unused link {} was not rolled back: {error}",
target_dir.display()
);
}
return None;
}
};
if let Err(error) = record_view(&config.target.root, workspace_root) {
log::warn!("the managed target directory was not recorded: {error}");
if let Err(error) = rollback_link(target_dir, &link) {
log::warn!(
"the unused link {} was not rolled back: {error}",
target_dir.display()
);
}
return None;
}
if let Err(error) = prepare_view(&managed, &link) {
log::warn!(
"the managed target directory {} was not prepared: {error}",
managed.display()
);
if matches!(&link, Link::Created) {
match rollback_link(target_dir, &link) {
Ok(()) => {
if let Err(error) = restore_record_state(&record_path, previous_record) {
log::warn!("the target record was not rolled back: {error}");
}
}
Err(error) => log::warn!(
"the unused link {} was not rolled back: {error}",
target_dir.display()
),
}
return None;
}
}
Some(managed)
}
enum Link {
Existing,
Created,
Replaced(PathBuf),
}
fn link_view(target_dir: &Path, managed: &Path, workspace_root: &Path) -> Result<Link> {
let replaced = match std::fs::read_link(target_dir) {
Ok(existing) if existing == managed => return Ok(Link::Existing),
Ok(existing) if replaceable_managed_link(&existing, managed, workspace_root) => {
remove_link(target_dir).wrap_err_with(|| {
format!(
"could not replace the outdated managed target link {}",
target_dir.display()
)
})?;
Some(existing)
}
Ok(existing) => {
eyre::bail!(
"{} already links to {}, so it was left alone",
target_dir.display(),
existing.display()
);
}
Err(_) if target_dir.exists() => {
eyre::bail!(
"{} is a real directory holding build outputs, so it was left alone; remove it to use a managed target directory",
target_dir.display()
);
}
Err(_) => None,
};
if let Err(error) = symlink_dir(managed, target_dir) {
if let Some(previous) = replaced
&& let Err(restore_error) = symlink_dir(&previous, target_dir)
{
eyre::bail!(
"could not link {} to a managed target directory: {error}; its previous managed link could not be restored: {restore_error}",
target_dir.display()
);
}
return Err(error).wrap_err_with(|| {
format!(
"could not link {} to a managed target directory",
target_dir.display()
)
});
}
Ok(replaced.map_or(Link::Created, Link::Replaced))
}
fn rollback_link(target_dir: &Path, link: &Link) -> Result<()> {
match link {
Link::Existing => Ok(()),
Link::Created => remove_link(target_dir).map_err(Into::into),
Link::Replaced(previous) => {
remove_link(target_dir)?;
symlink_dir(previous, target_dir).wrap_err("could not restore the previous link")
}
}
}
fn prepare_view(managed: &Path, link: &Link) -> Result<()> {
let Link::Replaced(previous) = link else {
return std::fs::create_dir_all(managed).map_err(Into::into);
};
match std::fs::rename(previous, managed) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(managed)?;
}
Err(_) => {
std::fs::create_dir_all(managed)?;
std::fs::remove_dir_all(previous).wrap_err_with(|| {
format!(
"could not retire the old target view {}",
previous.display()
)
})?;
}
}
let record = previous.with_extension("json");
if let Err(error) = std::fs::remove_file(&record)
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(error).wrap_err_with(|| {
format!(
"could not retire the old target record {}",
record.display()
)
});
}
Ok(())
}
fn replaceable_managed_link(existing: &Path, managed: &Path, workspace_root: &Path) -> bool {
let Some(name) = existing.file_name().and_then(|name| name.to_str()) else {
return false;
};
if !mbx_cache_core::is_task_identity(name) {
return false;
}
if let Some(record) = read_view_record(&existing.with_extension("json"))
&& view_key(&record.workspace_root) == name
{
return record.workspace_root == workspace_root
|| !crate::store::checkout_is_live(&record.workspace_root);
}
existing.parent() == managed.parent() && matches!(existing.try_exists(), Ok(false))
}
fn remove_link(path: &Path) -> std::io::Result<()> {
#[cfg(windows)]
{
std::fs::remove_dir(path)
}
#[cfg(not(windows))]
{
std::fs::remove_file(path)
}
}
#[cfg(unix)]
fn symlink_dir(source: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(source, link)
}
#[cfg(windows)]
fn symlink_dir(source: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(source, link)
}
pub fn touch_managed(config: &Config, workspace_root: &Path, target_dir: &Path) {
let record_path = view_record_path(&config.target.root, workspace_root);
if !record_path.exists() {
return;
}
let managed = view_dir(&config.target.root, workspace_root);
let (Ok(written), Ok(expected)) = (
std::fs::canonicalize(target_dir),
std::fs::canonicalize(&managed),
) else {
return;
};
if written != expected {
return;
}
if let Err(error) = record_view(&config.target.root, workspace_root) {
log::debug!("the managed target record was not refreshed: {error}");
}
}
pub fn stats(root: &Path) -> Result<ViewStats> {
let mut stats = ViewStats::default();
for (_, directory) in views(root)? {
stats.views += 1;
stats.bytes += tree_bytes(&directory);
}
Ok(stats)
}
pub fn prune(root: &Path) -> Result<PruneOutcome> {
let outcome = collect(root, None, None, false)?;
Ok(PruneOutcome {
removed_views: outcome.removed_views,
removed_bytes: outcome.removed_bytes,
})
}
pub(crate) fn collect(
root: &Path,
max_bytes: Option<u64>,
max_age: Option<Duration>,
dry_run: bool,
) -> Result<CollectionOutcome> {
let mut outcome = CollectionOutcome::default();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or_default();
let mut entries = Vec::new();
let mut uncollectable_bytes = 0_u64;
for (record_path, directory) in views(root)? {
let Some(record) = read_view_record(&record_path) else {
uncollectable_bytes = uncollectable_bytes.saturating_add(tree_bytes(&directory));
continue;
};
let bytes = tree_bytes(&directory);
entries.push((
record_path,
directory,
record.updated_secs,
bytes,
crate::store::checkout_is_live(&record.workspace_root),
));
}
let mut remaining = entries
.iter()
.map(|entry| entry.3)
.fold(uncollectable_bytes, u64::saturating_add);
let mut selected = HashSet::new();
for (record_path, _, updated, bytes, live) in &entries {
let expired = max_age.is_some_and(|age| now.saturating_sub(*updated) > age.as_secs());
if !live || expired {
selected.insert(record_path.clone());
remaining = remaining.saturating_sub(*bytes);
}
}
if let Some(max_bytes) = max_bytes
&& remaining > max_bytes
{
entries.sort_by_key(|entry| entry.2);
let mut candidates: Vec<&(PathBuf, PathBuf, u64, u64, bool)> = entries
.iter()
.filter(|entry| !selected.contains(&entry.0))
.collect();
candidates.pop();
for entry in candidates {
if remaining <= max_bytes {
break;
}
if selected.insert(entry.0.clone()) {
remaining = remaining.saturating_sub(entry.3);
}
}
if remaining > max_bytes {
log::warn!(
"managed target directories still hold {} after collection, over the {} budget; the most recently used one is kept",
bytesize::ByteSize::b(remaining).display().iec(),
bytesize::ByteSize::b(max_bytes).display().iec(),
);
}
}
for (record_path, directory, _, bytes, live) in entries {
if !selected.contains(&record_path) {
continue;
}
let removal = if dry_run {
Ok(())
} else {
std::fs::remove_dir_all(&directory)
};
match removal {
Ok(()) => {
outcome.removed_views += 1;
outcome.removed_bytes += bytes;
if live {
outcome.removed_live_views += 1;
} else {
outcome.removed_stale_views += 1;
}
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
outcome.removed_views += 1;
if live {
outcome.removed_live_views += 1;
} else {
outcome.removed_stale_views += 1;
}
}
Err(error) => {
remaining = remaining.saturating_add(bytes);
log::warn!(
"could not remove the target directory {}: {error}",
directory.display()
);
continue;
}
}
if !dry_run
&& let Err(error) = std::fs::remove_file(&record_path)
&& error.kind() != std::io::ErrorKind::NotFound
{
log::warn!(
"could not remove the target record {}: {error}",
record_path.display()
);
}
}
outcome.remaining_bytes = remaining;
Ok(outcome)
}
fn views(root: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
let directory = views_root(root);
let listing = match std::fs::read_dir(&directory) {
Ok(listing) => listing,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => {
return Err(error).wrap_err_with(|| format!("failed to read {}", directory.display()));
}
};
let mut views = Vec::new();
for entry in listing {
let entry = entry?;
let path = entry.path();
if path
.extension()
.is_some_and(|extension| extension == "json")
{
views.push((path.clone(), path.with_extension("")));
}
}
Ok(views)
}
fn record_view(root: &Path, workspace_root: &Path) -> Result<()> {
let record = ViewRecord {
version: VIEW_RECORD_VERSION,
workspace_root: workspace_root.to_path_buf(),
updated_secs: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default(),
};
let mut contents = serde_json::to_vec(&record)?;
contents.push(b'\n');
crate::util::write_atomic(&view_record_path(root, workspace_root), &contents)
}
fn read_record_state(path: &Path) -> Result<Option<Vec<u8>>> {
match std::fs::read(path) {
Ok(contents) => Ok(Some(contents)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error).wrap_err_with(|| format!("could not read {}", path.display())),
}
}
fn restore_record_state(path: &Path, previous: Option<Vec<u8>>) -> Result<()> {
if let Some(contents) = previous {
return crate::util::write_atomic(path, &contents);
}
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error).wrap_err_with(|| format!("could not remove {}", path.display())),
}
}
fn read_view_record(path: &Path) -> Option<ViewRecord> {
let bytes = std::fs::read(path).ok()?;
let record = serde_json::from_slice::<ViewRecord>(&bytes).ok()?;
(record.version == VIEW_RECORD_VERSION).then_some(record)
}
fn views_root(root: &Path) -> PathBuf {
let views = root.join(VIEWS_DIR);
std::path::absolute(&views).unwrap_or(views)
}
fn view_dir(root: &Path, workspace_root: &Path) -> PathBuf {
views_root(root).join(view_key(workspace_root))
}
fn view_record_path(root: &Path, workspace_root: &Path) -> PathBuf {
views_root(root).join(format!("{}.json", view_key(workspace_root)))
}
fn view_key(workspace_root: &Path) -> String {
CacheDigest::blake3(workspace_root.to_string_lossy().as_bytes()).hash
}
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 Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
pending.push(entry.path());
} else if file_type.is_file()
&& let Ok(metadata) = entry.metadata()
{
total += metadata.len();
}
}
}
total
}
#[cfg(test)]
#[path = "target_tests.rs"]
mod tests;