use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::time::SystemTime;
use regex::Regex;
use super::size::format_size;
use crate::error::{HoldError, Result};
use crate::logging::Logger;
use crate::timestamp::{saturating_duration_from_nanos, set_file_mtime};
#[derive(Debug, Clone)]
pub(crate) struct ArtifactInfo {
pub(crate) path: PathBuf,
pub(crate) size: u64,
pub(crate) _modified: SystemTime,
}
#[derive(Debug)]
pub(crate) struct CrateArtifact {
pub(crate) name: String,
pub(crate) hash: String,
pub(crate) artifacts: Vec<ArtifactInfo>,
pub(crate) total_size: u64,
pub(crate) newest_mtime: SystemTime,
}
pub(crate) fn collect_crate_artifacts(profile_dir: &Path) -> Result<Vec<CrateArtifact>> {
let fingerprint_dir = profile_dir.join(".fingerprint");
if !fingerprint_dir.exists() {
return Ok(Vec::new());
}
let mut crate_map: HashMap<(String, String), CrateArtifact> = HashMap::new();
let entries = fs::read_dir(&fingerprint_dir).map_err(|source| HoldError::IoError {
path: fingerprint_dir.clone(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| HoldError::IoError {
path: fingerprint_dir.clone(),
source,
})?;
let path = entry.path();
if path.is_dir()
&& let Some((name, hash)) = parse_crate_artifact_name(&path)
{
let key = (name.clone(), hash.clone());
let crate_artifact = crate_map.entry(key).or_insert_with(|| CrateArtifact {
name,
hash,
artifacts: Vec::new(),
total_size: 0,
newest_mtime: SystemTime::UNIX_EPOCH,
});
add_artifact_file(&path, crate_artifact)?;
}
}
for (subdir, _patterns) in &[("deps", vec!["*"]), ("build", vec!["*"])] {
let dir = profile_dir.join(subdir);
if !dir.exists() {
continue;
}
let entries = fs::read_dir(&dir).map_err(|source| HoldError::IoError {
path: dir.clone(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| HoldError::IoError {
path: dir.clone(),
source,
})?;
let path = entry.path();
if let Some((name, hash)) = parse_crate_artifact_name(&path) {
let key = (name.clone(), hash.clone());
if let Some(crate_artifact) = crate_map.get_mut(&key) {
add_artifact_file(&path, crate_artifact)?;
} else {
let mut artifact = CrateArtifact {
name: name.clone(),
hash: hash.clone(),
artifacts: Vec::new(),
total_size: 0,
newest_mtime: SystemTime::UNIX_EPOCH,
};
add_artifact_file(&path, &mut artifact)?;
crate_map.insert(key, artifact);
}
}
}
}
Ok(crate_map.into_values().collect())
}
pub(crate) fn parse_crate_artifact_name(path: &Path) -> Option<(String, String)> {
static CRATE_ARTIFACT_RE: OnceLock<Regex> = OnceLock::new();
let filename = path.file_name()?.to_str()?;
let re = CRATE_ARTIFACT_RE.get_or_init(|| {
Regex::new(r"^(.+)-([0-9a-f]{16})(?:\.|$)").expect("crate artifact regex should compile")
});
let captures = re.captures(filename)?;
Some((captures[1].to_string(), captures[2].to_string()))
}
fn add_artifact_files(path: &Path, crate_artifact: &mut CrateArtifact) -> Result<()> {
if path.is_file() {
add_artifact_file(path, crate_artifact)?;
} else if path.is_dir() {
let entries = fs::read_dir(path).map_err(|source| HoldError::IoError {
path: path.to_path_buf(),
source,
})?;
for entry in entries {
let entry = entry.map_err(|source| HoldError::IoError {
path: path.to_path_buf(),
source,
})?;
add_artifact_files(&entry.path(), crate_artifact)?;
}
}
Ok(())
}
fn add_artifact_file(path: &Path, crate_artifact: &mut CrateArtifact) -> Result<()> {
let metadata = fs::metadata(path).map_err(|source| HoldError::IoError {
path: path.to_path_buf(),
source,
})?;
if metadata.is_dir() {
add_artifact_files(path, crate_artifact)?;
let artifact_info = ArtifactInfo {
path: path.to_path_buf(),
size: 0, _modified: SystemTime::UNIX_EPOCH, };
crate_artifact.artifacts.push(artifact_info);
} else {
let modified = metadata.modified().map_err(|source| HoldError::IoError {
path: path.to_path_buf(),
source,
})?;
let artifact_info = ArtifactInfo {
path: path.to_path_buf(),
size: metadata.len(),
_modified: modified,
};
crate_artifact.total_size += artifact_info.size;
if modified > crate_artifact.newest_mtime {
crate_artifact.newest_mtime = modified;
}
crate_artifact.artifacts.push(artifact_info);
}
Ok(())
}
pub(crate) fn previous_build_preservation_cutoff(
previous_build_mtime_nanos: Option<u128>,
age_threshold_days: u32,
) -> Option<SystemTime> {
let previous_mtime_nanos = previous_build_mtime_nanos?;
if age_threshold_days == 0 {
return None;
}
let (duration, saturated) = saturating_duration_from_nanos(previous_mtime_nanos);
if saturated {
eprintln!(
"Warning: previous_build_mtime_nanos ({previous_mtime_nanos}) exceeds representable \
range; clamping to ~year 2554."
);
}
let mut previous_mtime = SystemTime::UNIX_EPOCH + duration;
let now = SystemTime::now();
if previous_mtime > now {
previous_mtime = now;
}
let age_threshold = std::time::Duration::from_secs(age_threshold_days as u64 * 24 * 60 * 60);
let elapsed_since_previous = now
.duration_since(previous_mtime)
.unwrap_or(std::time::Duration::ZERO);
if elapsed_since_previous > age_threshold {
return None;
}
let buffer = std::time::Duration::from_secs(5 * 60);
Some(
previous_mtime
.checked_sub(buffer)
.unwrap_or(SystemTime::UNIX_EPOCH),
)
}
pub(crate) fn rejuvenate_stale_artifact_mtimes(
artifacts: &mut [CrateArtifact],
previous_build_mtime_nanos: Option<u128>,
age_threshold_days: u32,
dry_run: bool,
verbose: u8,
quiet: bool,
) -> Result<usize> {
let log = Logger::new(verbose, quiet);
let Some(cutoff) =
previous_build_preservation_cutoff(previous_build_mtime_nanos, age_threshold_days)
else {
return Ok(0);
};
if artifacts.is_empty() {
return Ok(0);
}
if artifacts
.iter()
.any(|artifact| artifact.newest_mtime >= cutoff)
{
return Ok(0);
}
let age_cutoff = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
age_threshold_days as u64 * 24 * 60 * 60,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
let refresh_time = SystemTime::now();
let mut files_touched = 0usize;
let mut crates_refreshed = 0usize;
for artifact in artifacts.iter_mut() {
if artifact.newest_mtime < cutoff && artifact.newest_mtime >= age_cutoff {
if dry_run {
files_touched += artifact
.artifacts
.iter()
.filter(|info| info.path.is_file())
.count();
artifact.newest_mtime = refresh_time;
crates_refreshed += 1;
continue;
}
let mut newest = SystemTime::UNIX_EPOCH;
for info in &mut artifact.artifacts {
if info.path.is_file() {
set_file_mtime(&info.path, refresh_time)?;
info._modified = refresh_time;
files_touched += 1;
if refresh_time > newest {
newest = refresh_time;
}
}
}
if newest > artifact.newest_mtime {
artifact.newest_mtime = newest;
}
crates_refreshed += 1;
}
}
if crates_refreshed == 0 {
return Ok(0);
}
if !log.quiet() {
eprintln!(
" Refreshed mtimes on {crates_refreshed} crate artifact(s) with stale cache \
timestamps"
);
}
log.verbose(
1,
format!(" Refreshed mtimes on {files_touched} artifact file(s)"),
);
Ok(files_touched)
}
pub(crate) fn select_artifacts_for_removal(
crate_artifacts: &[CrateArtifact],
current_size: u64,
max_size: Option<u64>,
age_threshold_days: u32,
previous_build_mtime_nanos: Option<u128>,
verbose: u8,
quiet: bool,
) -> Vec<&CrateArtifact> {
let remaining = preserve_previous_build_artifacts(
crate_artifacts.iter().collect(),
previous_build_mtime_nanos,
age_threshold_days,
verbose,
quiet,
);
let (mut to_remove, remaining) = select_for_size(remaining, current_size, max_size, quiet);
let age_selected = select_for_age(remaining, age_threshold_days, verbose, quiet);
to_remove.extend(age_selected);
to_remove
}
fn preserve_previous_build_artifacts(
artifacts: Vec<&CrateArtifact>,
previous_build_mtime_nanos: Option<u128>,
age_threshold_days: u32,
verbose: u8,
quiet: bool,
) -> Vec<&CrateArtifact> {
let log = Logger::new(verbose, quiet);
if let Some(previous_mtime_nanos) = previous_build_mtime_nanos {
if let Some(cutoff_time) =
previous_build_preservation_cutoff(Some(previous_mtime_nanos), age_threshold_days)
{
let (preserved, eligible): (Vec<_>, Vec<_>) = artifacts
.into_iter()
.partition(|artifact| artifact.newest_mtime >= cutoff_time);
if !log.quiet() && !preserved.is_empty() {
let preserved_size: u64 = preserved.iter().map(|a| a.total_size).sum();
eprintln!(
" Preserving {} artifacts ({}) from previous build",
preserved.len(),
format_size(preserved_size)
);
if log.level() > 1 {
for artifact in &preserved {
eprintln!(" Preserving: {}-{}", artifact.name, artifact.hash);
}
}
}
return eligible;
}
log.verbose(
1,
" Skipping previous build preservation (no cutoff; age threshold 0 or last heave too \
old)",
);
}
artifacts
}
fn select_for_size(
mut remaining_artifacts: Vec<&CrateArtifact>,
current_size: u64,
max_size: Option<u64>,
quiet: bool,
) -> (Vec<&CrateArtifact>, Vec<&CrateArtifact>) {
let mut to_remove = Vec::new();
let log = Logger::new(0, quiet);
if let Some(max_size) = max_size {
if !log.quiet() {
eprintln!(
" Size-based cleanup: current={}, max={}",
format_size(current_size),
format_size(max_size)
);
}
if current_size > max_size {
let needed = current_size - max_size;
if !log.quiet() {
eprintln!(" Need to free: {}", format_size(needed));
}
remaining_artifacts.sort_by_key(|a| a.newest_mtime);
let mut freed = 0u64;
let mut kept_artifacts = Vec::new();
for artifact in remaining_artifacts {
if freed < needed {
to_remove.push(artifact);
freed += artifact.total_size;
} else {
kept_artifacts.push(artifact);
}
}
remaining_artifacts = kept_artifacts;
if !log.quiet() {
eprintln!(
" Size cleanup will remove {} crates, freeing {}",
to_remove.len(),
format_size(freed)
);
}
} else if !log.quiet() {
eprintln!(" Already within target size");
}
}
(to_remove, remaining_artifacts)
}
fn select_for_age(
remaining_artifacts: Vec<&CrateArtifact>,
age_threshold_days: u32,
verbose: u8,
quiet: bool,
) -> Vec<&CrateArtifact> {
let mut to_remove = Vec::new();
let log = Logger::new(verbose, quiet);
if !log.quiet() {
eprintln!(" Age-based cleanup: removing artifacts older than {age_threshold_days} days");
}
let cutoff = SystemTime::now()
.checked_sub(std::time::Duration::from_secs(
age_threshold_days as u64 * 24 * 60 * 60,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
let now = SystemTime::now();
let mut age_removed_count = 0;
let mut age_removed_size = 0u64;
for artifact in remaining_artifacts {
let age_days = now
.duration_since(artifact.newest_mtime)
.map(|d| d.as_secs() / (24 * 60 * 60))
.unwrap_or(0);
if artifact.newest_mtime < cutoff {
log.verbose(
2,
format!(
" Removing old crate {}: age={} days",
artifact.name, age_days
),
);
age_removed_count += 1;
age_removed_size += artifact.total_size;
to_remove.push(artifact);
}
}
if !log.quiet() {
eprintln!(
" Age cleanup will remove {} additional crates, freeing {}",
age_removed_count,
format_size(age_removed_size)
);
}
to_remove
}
pub(crate) fn remove_crate_artifacts(crate_artifact: &CrateArtifact) -> Result<()> {
for artifact in &crate_artifact.artifacts {
if artifact.path.exists() {
if artifact.path.is_dir() {
fs::remove_dir_all(&artifact.path).map_err(|source| HoldError::IoError {
path: artifact.path.clone(),
source,
})?;
} else {
fs::remove_file(&artifact.path).map_err(|source| HoldError::IoError {
path: artifact.path.clone(),
source,
})?;
}
}
}
Ok(())
}