use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const REMOVAL_PREFIX: &str = ".mbx-removing-";
const ACCESS_TIME_SLACK: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct UnitOutcome {
pub removed_units: u64,
pub removed_bytes: u64,
}
pub(crate) fn access_times_tracked(directory: &Path) -> bool {
let probe = || -> std::io::Result<bool> {
let file = tempfile::NamedTempFile::new_in(directory)?;
std::io::Write::write_all(&mut file.as_file(), b"access time probe")?;
let past = SystemTime::now() - 2 * ACCESS_TIME_SLACK;
file.as_file().set_times(
std::fs::FileTimes::new()
.set_accessed(past)
.set_modified(past),
)?;
std::fs::File::open(file.path())?.read_to_end(&mut Vec::new())?;
Ok(std::fs::metadata(file.path())?.accessed()? > past + ACCESS_TIME_SLACK)
};
probe().unwrap_or(false)
}
pub(crate) fn prune(view: &Path, max_age: Duration, now: SystemTime, dry_run: bool) -> UnitOutcome {
let mut outcome = UnitOutcome::default();
let Some(cutoff) = now.checked_sub(max_age.saturating_add(ACCESS_TIME_SLACK)) else {
return outcome;
};
for profile in profiles(view) {
outcome.removed_bytes += remove_abandoned_removals(&profile, dry_run);
let (units, doomed) = unused(&profile, cutoff);
if doomed.is_empty() {
continue;
}
let bytes = doomed
.iter()
.map(|path| crate::target::tree_bytes(path))
.sum::<u64>();
if !dry_run && let Err(error) = remove(&profile, &doomed) {
log::warn!(
"could not remove unused build units in {}: {error}",
profile.display()
);
continue;
}
outcome.removed_units += units;
outcome.removed_bytes += bytes;
}
outcome
}
fn profiles(view: &Path) -> Vec<PathBuf> {
let mut profiles = Vec::new();
let mut pending = subdirectories(view)
.into_iter()
.map(|directory| (directory, 1))
.collect::<Vec<_>>();
while let Some((directory, depth)) = pending.pop() {
if directory.join(".cargo-lock").is_file() {
profiles.push(directory);
} else if depth < 3 {
pending.extend(
subdirectories(&directory)
.into_iter()
.map(|child| (child, depth + 1)),
);
}
}
profiles
}
fn unused(profile: &Path, cutoff: SystemTime) -> (u64, Vec<PathBuf>) {
let mut units = 0;
let mut doomed = Vec::new();
let fingerprints = profile.join(".fingerprint");
let old_units = subdirectories(&fingerprints);
let old_last_use = old_units
.iter()
.map(|unit| last_use(unit))
.max()
.or_else(|| modified(&fingerprints));
if old_last_use.is_some_and(|used| used < cutoff) {
units += old_units.len() as u64;
doomed.push(fingerprints);
let deps = profile.join("deps");
if deps.is_dir() {
doomed.push(deps);
}
doomed.extend(
subdirectories(&profile.join("build"))
.into_iter()
.filter(|directory| is_old_unit(directory)),
);
} else if !fingerprints.exists() {
let deps = profile.join("deps");
if std::fs::read_dir(&deps).is_ok_and(|mut entries| entries.next().is_some()) {
doomed.push(deps);
}
doomed.extend(
subdirectories(&profile.join("build"))
.into_iter()
.filter(|directory| is_old_unit(directory)),
);
}
for package in subdirectories(&profile.join("build")) {
for unit in subdirectories(&package) {
let fingerprint = unit.join("fingerprint");
if fingerprint.is_dir() && last_use(&fingerprint) < cutoff {
units += 1;
doomed.push(unit);
}
}
}
(units, doomed)
}
fn is_old_unit(directory: &Path) -> bool {
let has_hash_suffix = directory
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.rsplit_once('-'))
.is_some_and(|(_, hash)| hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()));
has_hash_suffix
&& !subdirectories(directory)
.iter()
.any(|child| child.join("fingerprint").is_dir())
}
pub(crate) fn last_use(directory: &Path) -> SystemTime {
let mut latest = modified(directory).unwrap_or_else(SystemTime::now);
let Ok(listing) = std::fs::read_dir(directory) else {
return SystemTime::now();
};
for entry in listing.flatten() {
let Ok(metadata) = entry.metadata() else {
return SystemTime::now();
};
for time in [metadata.accessed(), metadata.modified()] {
match time {
Ok(time) => latest = latest.max(time),
Err(_) => return SystemTime::now(),
}
}
}
latest
}
fn modified(path: &Path) -> Option<SystemTime> {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
}
fn remove(profile: &Path, doomed: &[PathBuf]) -> std::io::Result<()> {
let removal = profile.join(format!("{REMOVAL_PREFIX}{}", std::process::id()));
std::fs::create_dir_all(&removal)?;
for (index, path) in doomed.iter().enumerate() {
std::fs::rename(path, removal.join(index.to_string()))?;
if let Some(package) = path.parent()
&& package.parent().and_then(Path::file_name) == Some("build".as_ref())
{
let _ = std::fs::remove_dir(package);
}
}
std::fs::remove_dir_all(&removal)
}
fn remove_abandoned_removals(profile: &Path, dry_run: bool) -> u64 {
let Ok(listing) = std::fs::read_dir(profile) else {
return 0;
};
let mut bytes = 0;
for entry in listing.flatten() {
if entry
.file_name()
.to_string_lossy()
.starts_with(REMOVAL_PREFIX)
{
let size = crate::target::tree_bytes(&entry.path());
if dry_run || std::fs::remove_dir_all(entry.path()).is_ok() {
bytes += size;
}
}
}
bytes
}
fn subdirectories(directory: &Path) -> Vec<PathBuf> {
let Ok(listing) = std::fs::read_dir(directory) else {
return Vec::new();
};
listing
.flatten()
.filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
.map(|entry| entry.path())
.collect()
}
#[cfg(test)]
#[path = "target_units_tests.rs"]
mod tests;