use super::{ResolvedSession, ValidationSession, fingerprint::session_toolchain_key};
use crate::snippets::error::{Error, Result};
use crate::snippets::scratch::ABANDONED_GRACE_SECS;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const TOOLCHAIN_CACHE_ROOT: &str = ".alef/snippets/cache";
const USE_STAMP: &str = ".alef-last-used";
const RECENCY_DEPTH: usize = 3;
pub const DEFAULT_TOOLCHAIN_CACHE_GENERATIONS: usize = 1;
pub(super) fn toolchain_cache_root(working_directory: &Path) -> PathBuf {
working_directory.join(TOOLCHAIN_CACHE_ROOT)
}
pub(super) struct ToolchainCaches {
root: PathBuf,
pub(super) go_build: PathBuf,
pub(super) zig_global: PathBuf,
pub(super) cargo_target: PathBuf,
}
impl ToolchainCaches {
pub(super) fn directories(&self) -> [&Path; 3] {
[&self.go_build, &self.zig_global, &self.cargo_target]
}
pub(super) fn mark_used(&self) {
let stamp = self.root.join(USE_STAMP);
if let Err(error) = std::fs::File::create(&stamp) {
tracing::debug!(stamp = %stamp.display(), error = %error, "could not stamp snippet toolchain cache use");
}
}
}
pub(super) fn cache_directories(session: &ValidationSession) -> ToolchainCaches {
let root = toolchain_cache_root(&session.working_directory).join(session_toolchain_key(session));
ToolchainCaches {
go_build: root.join("go-build"),
zig_global: root.join("zig-global"),
cargo_target: root.join("cargo-target"),
root,
}
}
pub(super) fn purge_stale_toolchain_caches(resolved: &[ResolvedSession<'_>], timeout_secs: u64, generations: usize) {
let Some(cutoff) = reclaim_cutoff(timeout_secs) else {
return;
};
let mut live: BTreeMap<&Path, BTreeSet<String>> = BTreeMap::new();
for (_, spec, session) in resolved {
live.entry(spec.working_directory.as_path())
.or_default()
.insert(session_toolchain_key(session));
}
for (working_directory, keys) in live {
if let Err(error) = purge_toolchain_cache_root(working_directory, &keys, cutoff, generations) {
tracing::warn!(
working_directory = %working_directory.display(),
error = %error,
"could not purge stale snippet toolchain caches"
);
}
}
}
fn reclaim_cutoff(timeout_secs: u64) -> Option<SystemTime> {
SystemTime::now().checked_sub(Duration::from_secs(timeout_secs.saturating_add(ABANDONED_GRACE_SECS)))
}
fn purge_toolchain_cache_root(
working_directory: &Path,
live: &BTreeSet<String>,
cutoff: SystemTime,
generations: usize,
) -> Result<()> {
let root = toolchain_cache_root(working_directory);
let entries = match std::fs::read_dir(&root) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => {
return Err(Error::Other(format!(
"reading snippet toolchain cache root {}: {error}",
root.display()
)));
}
};
let mut reclaimable = Vec::new();
for entry in entries {
let entry = entry.map_err(|error| {
Error::Other(format!(
"reading an entry in snippet toolchain cache root {}: {error}",
root.display()
))
})?;
let path = entry.path();
if !entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
remove(std::fs::remove_file(&path), &path)?;
continue;
}
if entry.file_name().to_str().is_some_and(|name| live.contains(name)) {
continue;
}
reclaimable.push((last_used(&path), path));
}
reclaimable.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
for (used, path) in reclaimable.into_iter().skip(generations) {
if used > cutoff {
continue;
}
remove(std::fs::remove_dir_all(&path), &path)?;
}
Ok(())
}
fn last_used(directory: &Path) -> SystemTime {
walkdir::WalkDir::new(directory)
.max_depth(RECENCY_DEPTH)
.into_iter()
.filter_map(std::result::Result::ok)
.filter_map(|entry| entry.metadata().ok())
.filter_map(|metadata| metadata.modified().ok())
.max()
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn remove(outcome: std::io::Result<()>, path: &Path) -> Result<()> {
match outcome {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(Error::Other(format!(
"removing stale snippet toolchain cache {}: {error}",
path.display()
))),
}
}
#[cfg(test)]
mod tests;