use crate::snippets::error::{Error, Result};
use crate::snippets::session::ValidationSession;
use crate::snippets::types::Language;
use std::path::{Path, PathBuf};
use std::time::Duration;
pub const SNIPPET_SCRATCH_ROOT: &str = ".alef/snippets/tmp";
const SCRATCH_PREFIX: &str = ".alef-snippet-";
pub(super) const ABANDONED_GRACE_SECS: u64 = 60;
const REMOVAL_ATTEMPTS: u32 = 3;
const REMOVAL_RETRY_DELAY: Duration = Duration::from_millis(50);
const fn resolves_package_from_the_scratch_file(language: Language) -> bool {
matches!(language, Language::Dart | Language::Go)
}
#[must_use]
pub fn scratch_root(language: Language, working_directory: &Path, manifest: Option<&Path>) -> PathBuf {
let base = manifest
.filter(|_| resolves_package_from_the_scratch_file(language))
.and_then(Path::parent)
.unwrap_or(working_directory);
base.join(SNIPPET_SCRATCH_ROOT)
}
#[derive(Debug)]
pub struct ScratchDir {
inner: tempfile::TempDir,
}
impl ScratchDir {
pub fn for_session(session: &ValidationSession) -> Result<Self> {
Self::in_root(&session.scratch_root())
}
pub fn isolated() -> Result<Self> {
let inner = tempfile::TempDir::new()
.map_err(|error| Error::Other(format!("allocating isolated snippet scratch: {error}")))?;
Ok(Self { inner })
}
pub fn rooted(root: &Path, timeout_secs: u64) -> Result<Self> {
let scratch_root = root.join(SNIPPET_SCRATCH_ROOT);
purge_stale_scratch_root(&scratch_root, timeout_secs)?;
Self::in_root(&scratch_root)
}
fn in_root(root: &Path) -> Result<Self> {
match root.parent() {
Some(parent) => crate::core::cache_dir::ensure_cache_dir(parent)
.and_then(|()| std::fs::create_dir_all(root))
.map_err(|error| Error::Other(format!("creating snippet scratch root {}: {error}", root.display())))?,
None => std::fs::create_dir_all(root)
.map_err(|error| Error::Other(format!("creating snippet scratch root {}: {error}", root.display())))?,
}
let inner = tempfile::Builder::new()
.prefix(SCRATCH_PREFIX)
.tempdir_in(root)
.map_err(|error| Error::Other(format!("allocating snippet scratch in {}: {error}", root.display())))?;
Ok(Self { inner })
}
#[must_use]
pub fn path(&self) -> &Path {
self.inner.path()
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let path = self.inner.path().to_path_buf();
for attempt in 1..=REMOVAL_ATTEMPTS {
match std::fs::remove_dir_all(&path) {
Ok(()) => return,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) if attempt == REMOVAL_ATTEMPTS => {
tracing::warn!(
scratch = %path.display(),
error = %error,
attempts = REMOVAL_ATTEMPTS,
"snippet scratch directory survived cleanup"
);
return;
}
Err(_) => std::thread::sleep(REMOVAL_RETRY_DELAY),
}
}
}
}
pub(crate) fn purge_stale_scratch_root(root: &Path, timeout_secs: u64) -> Result<()> {
purge_scratch_root_entries(
root,
Duration::from_secs(timeout_secs.saturating_add(ABANDONED_GRACE_SECS)),
)
}
fn purge_scratch_root_entries(root: &Path, abandoned_after: Duration) -> Result<()> {
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 scratch root {}: {error}",
root.display()
)));
}
};
for entry in entries.flatten() {
if !is_abandoned(&entry, abandoned_after) {
continue;
}
let path = entry.path();
let outcome = if entry.file_type().is_ok_and(|file_type| file_type.is_dir()) {
std::fs::remove_dir_all(&path)
} else {
std::fs::remove_file(&path)
};
if let Err(error) = outcome
&& error.kind() != std::io::ErrorKind::NotFound
{
return Err(Error::Other(format!(
"removing abandoned snippet scratch {}: {error}",
path.display()
)));
}
}
Ok(())
}
fn is_abandoned(entry: &std::fs::DirEntry, abandoned_after: Duration) -> bool {
entry
.metadata()
.and_then(|metadata| metadata.modified())
.is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age >= abandoned_after))
}
#[cfg(test)]
mod tests {
use super::{SNIPPET_SCRATCH_ROOT, ScratchDir, purge_stale_scratch_root, scratch_root};
use crate::snippets::session::ValidationSession;
use crate::snippets::types::Language;
use crate::snippets::validators::ValidatorRegistry;
use std::path::{Path, PathBuf};
fn session(language: Language, working_directory: PathBuf, manifest: Option<PathBuf>) -> ValidationSession {
ValidationSession {
language,
working_directory,
manifest,
fingerprint: "scratch-fixture".into(),
env: std::collections::BTreeMap::new(),
include_paths: Vec::new(),
rust_features: Vec::new(),
rust_dependencies: std::collections::BTreeMap::new(),
}
}
#[test]
fn every_registered_language_resolves_scratch_under_the_cache_root() {
let languages = ValidatorRegistry::default().languages();
assert!(
languages.len() > 20,
"the registry should carry every supported language, got {}",
languages.len()
);
let working_directory = Path::new("/workspace/packages/example");
let manifest_path = Path::new("/workspace/packages/example/nested/manifest.toml");
for language in languages {
for manifest in [None, Some(manifest_path)] {
let root = scratch_root(language, working_directory, manifest);
assert!(
root.ends_with(SNIPPET_SCRATCH_ROOT),
"{language} scratch must nest under {SNIPPET_SCRATCH_ROOT}, got {}",
root.display()
);
assert_ne!(
root, working_directory,
"{language} scratch must never be the working directory itself"
);
let inside_the_tree = root.starts_with(working_directory)
|| manifest
.and_then(Path::parent)
.is_some_and(|parent| root.starts_with(parent));
assert!(
inside_the_tree,
"{language} scratch must stay inside the session's own tree, got {}",
root.display()
);
}
}
}
#[test]
fn only_package_resolving_languages_move_scratch_under_the_manifest() {
let working_directory = Path::new("/workspace");
let manifest = Path::new("/workspace/packages/example/manifest.toml");
for language in [Language::Go, Language::Dart] {
assert_eq!(
scratch_root(language, working_directory, Some(manifest)),
Path::new("/workspace/packages/example").join(SNIPPET_SCRATCH_ROOT),
"{language} resolves its package from the scratch file"
);
}
for language in [Language::Rust, Language::C, Language::Python, Language::TypeScript] {
assert_eq!(
scratch_root(language, working_directory, Some(manifest)),
working_directory.join(SNIPPET_SCRATCH_ROOT),
"{language} must keep scratch under the working directory"
);
}
}
#[test]
fn a_session_allocates_scratch_inside_its_own_root_and_not_directly_in_the_tree() {
let directory = tempfile::tempdir().expect("working directory");
let session = session(Language::Python, directory.path().to_path_buf(), None);
let scratch = ScratchDir::for_session(&session).expect("scratch directory");
assert_eq!(
scratch.path().parent(),
Some(directory.path().join(SNIPPET_SCRATCH_ROOT).as_path()),
"scratch must nest under the cache root, not sit directly in the working directory"
);
let loose: Vec<_> = std::fs::read_dir(directory.path())
.expect("read working directory")
.filter_map(std::result::Result::ok)
.map(|entry| entry.file_name())
.filter(|name| name != ".alef")
.collect();
assert!(loose.is_empty(), "nothing may be written loose in the tree: {loose:?}");
}
#[test]
fn rooted_nests_scratch_under_the_same_cache_root_a_session_would() {
let root = tempfile::tempdir().expect("project root");
let scratch = ScratchDir::rooted(root.path(), 5).expect("rooted scratch directory");
assert_eq!(
scratch.path().parent(),
Some(root.path().join(SNIPPET_SCRATCH_ROOT).as_path()),
"rooted scratch must nest under the cache root, not sit directly in `root`"
);
}
#[test]
fn rooted_sweeps_its_root_without_deleting_a_freshly_touched_concurrent_entry() {
let root = tempfile::tempdir().expect("project root");
let scratch_root = root.path().join(SNIPPET_SCRATCH_ROOT);
let concurrent = scratch_root.join(".alef-snippet-concurrent");
std::fs::create_dir_all(&concurrent).expect("concurrent scratch");
let _scratch = ScratchDir::rooted(root.path(), 0).expect("rooted scratch directory");
assert!(
concurrent.exists(),
"a freshly touched entry from a concurrent run must survive rooted's sweep"
);
}
#[test]
fn each_allocation_gets_a_distinct_directory() {
let directory = tempfile::tempdir().expect("working directory");
let session = session(Language::Python, directory.path().to_path_buf(), None);
let first = ScratchDir::for_session(&session).expect("first scratch");
let second = ScratchDir::for_session(&session).expect("second scratch");
assert_ne!(first.path(), second.path());
}
#[test]
fn dropping_the_guard_removes_the_directory_and_everything_in_it() {
let directory = tempfile::tempdir().expect("working directory");
let session = session(Language::Python, directory.path().to_path_buf(), None);
let scratch = ScratchDir::for_session(&session).expect("scratch directory");
let path = scratch.path().to_path_buf();
std::fs::create_dir_all(path.join("cache/nested")).expect("nested cache");
std::fs::write(path.join("snippet.py"), "value = 1\n").expect("scratch source");
std::fs::write(path.join("cache/nested/artifact"), "artifact").expect("cached artifact");
drop(scratch);
assert!(!path.exists(), "the guard must remove its directory on drop");
let root = directory.path().join(SNIPPET_SCRATCH_ROOT);
let remaining = std::fs::read_dir(&root)
.map(|entries| entries.flatten().count())
.unwrap_or(0);
assert_eq!(remaining, 0, "no scratch may survive under {}", root.display());
}
#[test]
fn the_sweep_removes_every_abandoned_shape_including_populated_directories() {
let directory = tempfile::tempdir().expect("working directory");
let root = directory.path().join(SNIPPET_SCRATCH_ROOT);
std::fs::create_dir_all(&root).expect("scratch root");
let abandoned_directory = root.join(".alef-snippet-abandoned");
std::fs::create_dir_all(abandoned_directory.join("nested")).expect("abandoned scratch");
std::fs::write(abandoned_directory.join("nested/snippet.go"), "package main\n").expect("abandoned source");
let abandoned_file = root.join(".tmpabandoned.rb");
std::fs::write(&abandoned_file, "puts 1\n").expect("abandoned file");
super::purge_scratch_root_entries(&root, std::time::Duration::ZERO).expect("sweep runs");
assert!(!abandoned_directory.exists(), "an abandoned directory must be swept");
assert!(!abandoned_file.exists(), "an abandoned loose file must be swept");
assert!(root.is_dir(), "the sweep must keep the root itself");
}
#[test]
fn the_sweep_spares_scratch_a_concurrent_run_may_still_be_using() {
let directory = tempfile::tempdir().expect("working directory");
let root = directory.path().join(SNIPPET_SCRATCH_ROOT);
let live = root.join(".alef-snippet-live");
std::fs::create_dir_all(&live).expect("live scratch");
purge_stale_scratch_root(&root, 0).expect("sweep runs");
assert!(live.exists(), "a freshly touched entry must survive the sweep");
}
#[test]
fn the_sweep_is_a_no_op_when_the_root_was_never_created() {
let directory = tempfile::tempdir().expect("working directory");
purge_stale_scratch_root(&directory.path().join(SNIPPET_SCRATCH_ROOT), 30)
.expect("sweep tolerates a missing root");
}
}