use crate::config::Config;
use eyre::{Context, Result};
use mbx_cache_core::CacheDigest;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const VIEWS_DIR: &str = "v1";
const REMOVAL_SUFFIX: &str = ".removing-";
const VIEW_RECORD_VERSION: u8 = 1;
const RECENTLY_CLAIMED: u64 = 2;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct ViewRecord {
version: u8,
workspace_root: PathBuf,
updated_secs: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct Precedence {
pub keep: Vec<PathBuf>,
pub evict_first: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Standing {
EvictFirst,
Normal,
Keep,
}
impl Precedence {
pub(crate) fn standing(&self, workspace_root: &Path) -> Standing {
if self.keep.is_empty() && self.evict_first.is_empty() {
return Standing::Normal;
}
let resolved = std::fs::canonicalize(workspace_root).ok();
let deepest = |entries: &[PathBuf]| {
deepest_match(entries, workspace_root).max(
resolved
.as_deref()
.and_then(|resolved| deepest_match(entries, resolved)),
)
};
let keep = deepest(&self.keep);
let evict = deepest(&self.evict_first);
match (keep, evict) {
(Some(keep), Some(evict)) if evict > keep => Standing::EvictFirst,
(Some(_), _) => Standing::Keep,
(None, Some(_)) => Standing::EvictFirst,
(None, None) => Standing::Normal,
}
}
}
fn deepest_match(entries: &[PathBuf], workspace_root: &Path) -> Option<(usize, usize)> {
let components: Vec<_> = workspace_root.components().collect();
entries
.iter()
.filter_map(|entry| {
let wanted: Vec<_> = entry.components().collect();
if wanted.is_empty() {
return None;
}
if entry.is_absolute() {
return components
.starts_with(&wanted)
.then_some((wanted.len(), wanted.len()));
}
components
.windows(wanted.len())
.rposition(|window| window == wanted.as_slice())
.map(|start| (start + wanted.len(), wanted.len()))
})
.max()
}
#[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 kept_active_views: u64,
pub removed_units: u64,
pub removed_unit_bytes: u64,
pub remaining_bytes: u64,
pub remaining_views: u64,
}
impl CollectionOutcome {
pub(crate) fn freed_bytes(&self) -> u64 {
self.removed_bytes.saturating_add(self.removed_unit_bytes)
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct MigrationOutcome {
pub managed: Option<PathBuf>,
pub removed_bytes: Option<u64>,
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct AdoptionOutcome {
pub managed: Option<PathBuf>,
pub adopted_bytes: u64,
}
#[derive(Debug)]
pub struct StrandedAdoption {
pub retained: PathBuf,
}
impl std::fmt::Display for StrandedAdoption {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"the old target directory was retained at {}",
self.retained.display()
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoveOutcome {
Missing,
Removed(u64),
Active,
}
pub(crate) struct ViewLease {
_file: std::fs::File,
}
impl ViewLease {
pub(crate) fn acquire(root: &Path, workspace_root: &Path) -> Result<Self> {
let path = view_lock_path(&view_dir(root, workspace_root));
let file =
open_view_lock(&path).wrap_err_with(|| format!("could not open {}", path.display()))?;
file.lock_shared()
.wrap_err_with(|| format!("could not lock {}", path.display()))?;
Ok(Self { _file: file })
}
}
pub(crate) fn is_recorded(root: &Path, workspace_root: &Path) -> bool {
view_record_path(root, workspace_root).exists()
}
fn view_lock_path(directory: &Path) -> PathBuf {
directory.with_extension("lock")
}
fn open_view_lock(path: &Path) -> std::io::Result<std::fs::File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)
}
fn try_reserve_view(directory: &Path) -> Option<std::fs::File> {
let path = view_lock_path(directory);
let file = match open_view_lock(&path) {
Ok(file) => file,
Err(error) => {
log::warn!(
"could not tell whether {} is in use: {error}",
directory.display()
);
return None;
}
};
match file.try_lock() {
Ok(()) => Some(file),
Err(std::fs::TryLockError::WouldBlock) => None,
Err(std::fs::TryLockError::Error(error)) => {
log::warn!(
"could not tell whether {} is in use: {error}",
directory.display()
);
None
}
}
}
fn view_in_use(directory: &Path) -> bool {
let file = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(view_lock_path(directory))
{
Ok(file) => file,
Err(error) => return error.kind() != std::io::ErrorKind::NotFound,
};
file.try_lock().is_err()
}
fn retire_view(directory: &Path) -> std::io::Result<PathBuf> {
let aside = removal_path(directory);
std::fs::rename(directory, &aside)?;
Ok(aside)
}
pub fn remove_workspace(root: &Path, workspace_root: &Path) -> Result<RemoveOutcome> {
let record_path = view_record_path(root, workspace_root);
let Some(record) = read_view_record(&record_path) else {
let directory = view_dir(root, workspace_root);
let link = workspace_root.join("target");
if !directory.exists()
&& std::fs::read_link(&link).is_ok_and(|destination| destination == directory)
{
remove_link(&link)?;
return Ok(RemoveOutcome::Removed(0));
}
return Ok(RemoveOutcome::Missing);
};
if record.workspace_root != workspace_root {
return Ok(RemoveOutcome::Missing);
}
let directory = record_path.with_extension("");
let Some(reservation) = try_reserve_view(&directory) else {
return Ok(RemoveOutcome::Active);
};
let aside = match retire_view(&directory) {
Ok(aside) => Some(aside),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
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)?;
}
drop(reservation);
let mut bytes = 0;
if let Some(aside) = aside {
bytes = tree_bytes(&aside);
std::fs::remove_dir_all(&aside)
.wrap_err_with(|| format!("could not remove {}", aside.display()))?;
}
Ok(RemoveOutcome::Removed(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);
if let Err(error) = restore_backup(target_dir, &managed, &backup) {
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 can_move_existing(config: &Config, workspace_root: &Path, target_dir: &Path) -> bool {
let managed = view_dir(&config.target.root, workspace_root);
managed
.ancestors()
.find(|ancestor| ancestor.exists())
.is_some_and(|existing| crate::store::same_filesystem_paths(target_dir, existing))
}
pub fn adopt_existing(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
) -> Result<AdoptionOutcome> {
adopt_existing_with(config, workspace_root, target_dir, requested, || {
place(config, workspace_root, target_dir, requested)
})
}
fn adopt_existing_with(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
place_target: impl FnOnce() -> Option<PathBuf>,
) -> Result<AdoptionOutcome> {
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 adopted",
target_dir.display()
);
}
if !can_remove_existing(config, workspace_root, target_dir, requested) {
eyre::bail!("{} is not eligible for adoption", target_dir.display());
}
if !can_move_existing(config, workspace_root, target_dir) {
eyre::bail!(
"{} is not on the same filesystem as the managed target root {}, so it was not adopted",
target_dir.display(),
config.target.root.display()
);
}
let Some(build_locks) = cargo_locks(target_dir)? else {
eyre::bail!(
"Cargo is using {}, so it was not adopted",
target_dir.display()
);
};
let adopted_bytes = tree_bytes(target_dir);
let managed = view_dir(&config.target.root, workspace_root);
if let Some(parent) = managed.parent() {
std::fs::create_dir_all(parent)
.wrap_err_with(|| format!("could not create {}", parent.display()))?;
}
let record_path = view_record_path(&config.target.root, workspace_root);
let previous_record = read_record_state(&record_path)?;
record_view(&config.target.root, workspace_root)?;
drop(build_locks);
if let Err(error) = move_into_view(target_dir, &managed) {
if let Err(restore_error) = restore_record_state(&record_path, previous_record) {
log::warn!("the target record was not rolled back: {restore_error}");
}
return Err(error);
}
if let Some(placed) = place_target() {
return Ok(AdoptionOutcome {
managed: Some(placed),
adopted_bytes,
});
}
let restore = (|| -> Result<()> {
match std::fs::symlink_metadata(target_dir) {
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 adoption"),
}
std::fs::rename(&managed, target_dir)
.wrap_err_with(|| format!("could not restore {}", target_dir.display()))?;
restore_record_state(&record_path, previous_record)
})();
match restore {
Ok(()) => Ok(AdoptionOutcome::default()),
Err(error) => Err(error.wrap_err(StrandedAdoption { retained: managed })),
}
}
fn move_into_view(target_dir: &Path, managed: &Path) -> Result<()> {
let refused = match std::fs::rename(target_dir, managed) {
Ok(()) => return Ok(()),
Err(error) => error,
};
if std::fs::symlink_metadata(managed).is_err() {
return Err(refused)
.wrap_err_with(|| format!("could not move the outputs into {}", managed.display()));
}
std::fs::remove_dir(managed).wrap_err_with(|| {
format!(
"could not replace the managed target directory {}",
managed.display()
)
})?;
std::fs::rename(target_dir, managed)
.wrap_err_with(|| format!("could not move the outputs into {}", managed.display()))
}
fn restore_backup(target_dir: &Path, managed: &Path, backup: &Path) -> 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()))
}
pub(crate) fn placement_candidate(
config: &Config,
workspace_root: &Path,
target_dir: &Path,
requested: bool,
) -> bool {
if !config.target.views || requested || target_dir != workspace_root.join("target") {
return false;
}
let managed = view_dir(&config.target.root, workspace_root);
match std::fs::read_link(target_dir) {
Ok(existing) => {
existing == managed || replaceable_managed_link(&existing, &managed, workspace_root)
}
Err(_) => !target_dir.exists(),
}
}
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 build_locks = match lock_replaced_view(target_dir, &managed, workspace_root) {
Ok(locks) => locks,
Err(error) => {
log::debug!("leaving the managed target directory in place: {error:#}");
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;
}
drop(build_locks);
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;
}
}
if let Err(error) = ignore_managed_link(workspace_root, target_dir) {
log::debug!("the managed target link was not excluded from Git: {error:#}");
}
Some(managed)
}
fn ignore_managed_link(workspace_root: &Path, target_dir: &Path) -> Result<()> {
let ignored = Command::new("git")
.current_dir(workspace_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.args(["check-ignore", "--quiet", "--no-index", "--"])
.arg(target_dir)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
if ignored.is_ok_and(|status| status.success()) {
return Ok(());
}
let paths = Command::new("git")
.current_dir(workspace_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_COMMON_DIR")
.args([
"rev-parse",
"--show-prefix",
"--path-format=absolute",
"--git-path",
"info/exclude",
])
.output()
.wrap_err("could not locate the Git repository")?;
if !paths.status.success() {
eyre::bail!("the workspace is not in a Git repository");
}
let paths = String::from_utf8(paths.stdout).wrap_err("Git returned non-UTF-8 paths")?;
let mut paths = paths
.split_terminator('\n')
.map(|path| path.trim_end_matches('\r'));
let prefix = PathBuf::from(
paths
.next()
.ok_or_else(|| eyre::eyre!("Git did not report the workspace prefix"))?,
);
let exclude = PathBuf::from(
paths
.next()
.ok_or_else(|| eyre::eyre!("Git did not report its exclude file"))?,
);
let relative = prefix.join(
target_dir
.strip_prefix(workspace_root)
.wrap_err("the target link is outside the Cargo workspace")?,
);
let pattern = format!("/{}", gitignore_path(&relative)?);
let existing = std::fs::read(&exclude).unwrap_or_default();
if existing
.split(|byte| *byte == b'\n')
.any(|line| line.strip_suffix(b"\r").unwrap_or(line) == pattern.as_bytes())
{
return Ok(());
}
if let Some(parent) = exclude.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&exclude)?;
let mut addition = Vec::with_capacity(pattern.len() + 2);
if !existing.is_empty() && !existing.ends_with(b"\n") {
addition.push(b'\n');
}
writeln!(addition, "{pattern}")?;
file.write_all(&addition)?;
Ok(())
}
fn gitignore_path(path: &Path) -> Result<String> {
let path = path.to_string_lossy().replace('\\', "/");
if path.contains(['\n', '\r']) {
eyre::bail!("the target path contains a newline");
}
let mut escaped = String::with_capacity(path.len());
for character in path.chars() {
if matches!(character, '\\' | '*' | '?' | '[' | ']' | '#' | '!') {
escaped.push('\\');
}
escaped.push(character);
}
Ok(escaped)
}
enum Link {
Existing,
Created,
Replaced(PathBuf),
}
#[derive(Default)]
struct ReplacedViewLocks {
_cargo: Vec<fslock::LockFile>,
_reservation: Option<std::fs::File>,
}
fn lock_replaced_view(
target_dir: &Path,
managed: &Path,
workspace_root: &Path,
) -> Result<ReplacedViewLocks> {
let Ok(existing) = std::fs::read_link(target_dir) else {
return Ok(ReplacedViewLocks::default());
};
if existing == managed
|| !replaceable_managed_link(&existing, managed, workspace_root)
|| !existing.exists()
{
return Ok(ReplacedViewLocks::default());
}
let Some(cargo) = cargo_locks(&existing)? else {
eyre::bail!("Cargo is using {}", existing.display());
};
let Some(reservation) = try_reserve_view(&existing) else {
eyre::bail!("a command is using {}", existing.display());
};
Ok(ReplacedViewLocks {
_cargo: cargo,
_reservation: Some(reservation),
})
}
fn cargo_locks(directory: &Path) -> Result<Option<Vec<fslock::LockFile>>> {
let mut pending = vec![(directory.to_path_buf(), 0)];
let mut locks = Vec::new();
while let Some((directory, depth)) = pending.pop() {
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
let mut is_profile = false;
let mut children = Vec::new();
for entry in entries {
let entry = entry?;
let kind = entry.file_type()?;
if kind.is_file() && entry.file_name() == ".cargo-lock" {
let mut lock = fslock::LockFile::open(&entry.path())?;
if !lock.try_lock()? {
return Ok(None);
}
locks.push(lock);
is_profile = true;
} else if kind.is_dir() && depth < 3 {
children.push((entry.file_name(), entry.path()));
}
}
for (name, path) in children {
if is_profile && is_profile_output_dir(&name) {
continue;
}
pending.push((path, depth + 1));
}
}
Ok(Some(locks))
}
fn is_profile_output_dir(name: &std::ffi::OsStr) -> bool {
matches!(
name.to_str(),
Some("deps" | "build" | "incremental" | "examples" | ".fingerprint" | "doc")
)
}
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_on(
managed.parent().unwrap_or(managed),
&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> {
collect_by(root, max_bytes, max_age, &Precedence::default(), dry_run)
}
pub(crate) fn collect_by(
root: &Path,
max_bytes: Option<u64>,
max_age: Option<Duration>,
precedence: &Precedence,
dry_run: bool,
) -> Result<CollectionOutcome> {
collect_with(
root,
max_bytes,
max_age,
precedence,
dry_run,
now_secs(),
|| {},
|_| {},
|| {},
)
}
#[allow(clippy::too_many_arguments)]
fn collect_with(
root: &Path,
max_bytes: Option<u64>,
max_age: Option<Duration>,
precedence: &Precedence,
dry_run: bool,
now: u64,
before_removal: impl FnOnce(),
mut while_reserved: impl FnMut(&Path),
mut after_removal: impl FnMut(),
) -> Result<CollectionOutcome> {
let mut outcome = CollectionOutcome::default();
if !dry_run {
remove_abandoned_removals(root);
}
let mut entries = Vec::new();
let mut total_views = 0_u64;
let mut uncollectable_bytes = 0_u64;
for (record_path, directory) in views(root)? {
total_views += 1;
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_on(root, &record.workspace_root),
precedence.standing(&record.workspace_root),
));
}
let expired = |updated: u64, standing: Standing| {
standing != Standing::Keep
&& max_age.is_some_and(|age| now.saturating_sub(updated) > age.as_secs())
};
if let Some(max_age) = max_age
&& entries
.iter()
.any(|entry| entry.4 && entry.5 != Standing::Keep && !expired(entry.2, entry.5))
&& crate::target_units::access_times_tracked(&views_root(root))
{
for (_, directory, updated, bytes, live, standing) in &mut entries {
if !*live || *standing == Standing::Keep || expired(*updated, *standing) {
continue;
}
let _reservation = if dry_run {
if view_in_use(directory) {
continue;
}
None
} else {
let Some(reservation) = try_reserve_view(directory) else {
continue;
};
Some(reservation)
};
let _locks = if dry_run {
None
} else {
match cargo_locks(directory) {
Ok(Some(locks)) => Some(locks),
Ok(None) => continue,
Err(error) => {
log::warn!(
"could not tell whether {} is in use: {error}",
directory.display()
);
continue;
}
}
};
let units = crate::target_units::prune(
directory,
max_age,
UNIX_EPOCH + Duration::from_secs(now),
dry_run,
);
*bytes = bytes.saturating_sub(units.removed_bytes);
outcome.removed_units += units.removed_units;
outcome.removed_unit_bytes += units.removed_bytes;
}
}
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, standing) in &entries {
if !live || expired(*updated, *standing) {
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, Standing)> = entries
.iter()
.filter(|entry| !selected.contains(&entry.0))
.collect();
candidates.pop();
candidates.retain(|entry| entry.5 != Standing::Keep);
candidates.sort_by_key(|entry| entry.5);
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 {
let kept = if entries.iter().any(|entry| entry.5 == Standing::Keep) {
" and those in target.keep are"
} else {
" is"
};
log::warn!(
"managed target directories still hold {} after collection, over the {} budget; the most recently used one{kept} kept",
bytesize::ByteSize::b(remaining).display().iec(),
bytesize::ByteSize::b(max_bytes).display().iec(),
);
}
}
before_removal();
for (record_path, directory, updated, bytes, live, _) in entries {
if !selected.contains(&record_path) {
continue;
}
let reservation = if dry_run {
if view_in_use(&directory) {
outcome.kept_active_views += 1;
remaining = remaining.saturating_add(bytes);
continue;
}
None
} else {
let Some(reservation) = try_reserve_view(&directory) else {
outcome.kept_active_views += 1;
remaining = remaining.saturating_add(bytes);
continue;
};
while_reserved(&directory);
Some(reservation)
};
if !dry_run {
let claimed_since = read_view_record(&record_path)
.is_some_and(|record| recently_claimed(root, &record, updated, now));
let in_use = match cargo_locks(&directory) {
Ok(locks) => locks.is_none(),
Err(error) => {
log::warn!(
"could not tell whether {} is in use: {error}",
directory.display()
);
true
}
};
if claimed_since || in_use {
outcome.kept_active_views += 1;
remaining = remaining.saturating_add(bytes);
continue;
}
}
let aside = if dry_run {
Ok(None)
} else {
retire_view(&directory).map(Some)
};
let aside = match aside {
Ok(aside) => {
outcome.removed_views += 1;
outcome.removed_bytes += bytes;
if live {
outcome.removed_live_views += 1;
} else {
outcome.removed_stale_views += 1;
}
aside
}
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;
}
None
}
Err(error) => {
remaining = remaining.saturating_add(bytes);
log::warn!(
"could not remove the target directory {}: {error}",
directory.display()
);
continue;
}
};
if !dry_run {
after_removal();
}
let superseded = !dry_run
&& (read_view_record(&record_path)
.is_some_and(|record| recently_claimed(root, &record, updated, now))
|| directory.exists());
if !dry_run
&& !superseded
&& 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()
);
}
drop(reservation);
if let Some(aside) = aside
&& let Err(error) = std::fs::remove_dir_all(&aside)
{
let left = tree_bytes(&aside);
outcome.removed_bytes = outcome.removed_bytes.saturating_sub(left);
remaining = remaining.saturating_add(left);
log::warn!(
"could not remove the retired target directory {}: {error}",
aside.display()
);
}
}
outcome.remaining_bytes = remaining;
outcome.remaining_views = total_views.saturating_sub(outcome.removed_views);
Ok(outcome)
}
pub(crate) fn seed_donors(root: &Path, workspace_root: &Path) -> Vec<crate::target_seed::Donor> {
let Ok(views) = views(root) else {
return Vec::new();
};
let mut donors = views
.into_iter()
.filter_map(|(record_path, directory)| {
let record = read_view_record(&record_path)?;
(record.workspace_root != workspace_root && directory.is_dir()).then_some(
crate::target_seed::Donor {
directory,
workspace_root: record.workspace_root,
updated_secs: record.updated_secs,
},
)
})
.collect::<Vec<_>>();
donors.sort_by_key(|donor| std::cmp::Reverse(donor.updated_secs));
donors
}
fn recently_claimed(root: &Path, record: &ViewRecord, updated: u64, now: u64) -> bool {
if record.updated_secs > updated {
return true;
}
now.saturating_sub(record.updated_secs) <= RECENTLY_CLAIMED
&& crate::store::checkout_is_live_on(root, &record.workspace_root)
}
fn removal_path(directory: &Path) -> PathBuf {
let name = directory
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
directory.with_file_name(format!("{name}{REMOVAL_SUFFIX}{}", std::process::id()))
}
fn remove_abandoned_removals(root: &Path) {
let Ok(listing) = std::fs::read_dir(views_root(root)) else {
return;
};
for entry in listing.flatten() {
if entry.file_name().to_string_lossy().contains(REMOVAL_SUFFIX)
&& entry.file_type().is_ok_and(|kind| kind.is_dir())
{
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
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: now_secs(),
};
let mut contents = serde_json::to_vec(&record)?;
contents.push(b'\n');
crate::util::write_advisory(&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_advisory(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)
}
pub(crate) 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
}
pub(crate) 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;
#[cfg(test)]
#[path = "target_lease_tests.rs"]
mod lease_tests;