use std::fs;
use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use sha2::{Digest, Sha256};
use crate::Options;
const CACHE_EPOCH: &str = "schema_v17";
#[must_use]
pub fn cache_key(repo_path: &Path, head_sha: &str, opts: &Options) -> [u8; 32] {
let mut hasher = Sha256::new();
let canonical = canonicalize_with_fallback_log(repo_path, "cache_key");
hasher.update(canonical.to_string_lossy().as_bytes());
hasher.update(b"\x00");
hasher.update(head_sha.as_bytes());
hasher.update(b"\x00");
hasher.update(env!("CARGO_PKG_VERSION").as_bytes());
hasher.update(b"\x00");
hasher.update(opts_hash(opts).as_bytes());
hasher.update(b"\x00");
hasher.update(CACHE_EPOCH.as_bytes());
let mut out = [0u8; 32];
out.copy_from_slice(&hasher.finalize());
out
}
#[must_use]
pub fn cache_path(key: &[u8; 32], repo_path: &Path) -> PathBuf {
cache_path_with_root(key, repo_path, &default_cache_root())
}
#[must_use]
pub fn cache_path_with_root(key: &[u8; 32], repo_path: &Path, root: &Path) -> PathBuf {
let repo_short = repo_hash_short(repo_path);
let key_short = hex::encode(&key[..8]); root.join("codelore")
.join(repo_short)
.join(format!("{key_short}.duckdb"))
}
#[must_use]
pub fn repo_cache_dir(cache_root: &Path, repo_path: &Path) -> PathBuf {
cache_root.join("codelore").join(repo_hash_short(repo_path))
}
fn repo_hash_short(repo_path: &Path) -> String {
let canonical = canonicalize_with_fallback_log(repo_path, "repo_hash_short");
let mut hasher = Sha256::new();
hasher.update(canonical.to_string_lossy().as_bytes());
hex::encode(&hasher.finalize()[..4]) }
#[must_use]
pub fn default_cache_root() -> PathBuf {
dirs::cache_dir().unwrap_or_else(fallback_tmp_root)
}
fn fallback_tmp_root() -> PathBuf {
let id = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.or_else(|_| std::env::var("USERNAME"))
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| format!("pid{}", std::process::id()));
PathBuf::from(format!("/tmp/codelore-fallback-{id}"))
}
fn opts_hash(opts: &Options) -> String {
opts.canonical_json().to_string()
}
fn canonicalize_with_fallback_log(repo_path: &Path, call_site: &str) -> PathBuf {
match fs::canonicalize(repo_path) {
Ok(canonical) => canonical,
Err(e) => {
tracing::debug!(
"{}: fs::canonicalize fallback for repo_path={} ({}); using raw path",
call_site,
repo_path.display(),
e,
);
repo_path.to_path_buf()
}
}
}
pub fn prune_repo_cache(repo_dir: &Path, max_entries: usize) {
cleanup_stale_tmp_files(repo_dir);
let Ok(rd) = fs::read_dir(repo_dir) else {
return;
};
let mut entries: Vec<(PathBuf, u64)> = rd
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x == "duckdb")
})
.filter_map(|e| {
let mtime = e
.metadata()
.ok()?
.modified()
.ok()?
.duration_since(UNIX_EPOCH)
.ok()?
.as_secs();
Some((e.path(), mtime))
})
.collect();
if entries.len() <= max_entries {
return;
}
entries.sort_by_key(|(_, mtime)| *mtime);
let to_delete = entries.len() - max_entries;
for (path, _) in entries.into_iter().take(to_delete) {
delete_duckdb_with_companion(&path, "prune_repo_cache");
}
}
pub fn prune_global_cache(root: &Path, max_bytes: u64) {
let codelore_dir = root.join("codelore");
cleanup_stale_tmp_files_recursive(&codelore_dir);
let walk = collect_duckdb_files(&codelore_dir);
let total: u64 = walk.iter().map(|(_, _, size)| *size).sum();
if total <= max_bytes {
return;
}
let mut files = walk;
files.sort_by_key(|(_, mtime, _)| *mtime);
let mut remaining = total;
for (path, _, size) in files {
if remaining <= max_bytes {
break;
}
let existed = path.exists();
delete_duckdb_with_companion(&path, "prune_global_cache");
if existed && !path.exists() {
remaining = remaining.saturating_sub(size);
}
}
}
fn collect_duckdb_files(dir: &Path) -> Vec<(PathBuf, u64, u64)> {
let mut out = Vec::new();
collect_duckdb_files_inner(dir, &mut out);
out
}
fn collect_duckdb_files_inner(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) {
let rd = match fs::read_dir(dir) {
Ok(rd) => rd,
Err(e) => {
tracing::warn!("collect_duckdb_files: skipping {} ({e})", dir.display());
return;
}
};
for entry in rd.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(e) => {
tracing::warn!("collect_duckdb_files: skipping {} ({e})", path.display());
continue;
}
};
if meta.is_dir() {
collect_duckdb_files_inner(&path, out);
} else if path
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x == "duckdb")
{
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map_or(0, |d| d.as_secs());
out.push((path, mtime, meta.len()));
}
}
}
fn delete_duckdb_with_companion(path: &Path, ctx: &str) {
match fs::remove_file(path) {
Ok(()) => tracing::info!("{ctx}: removed {}", path.display()),
Err(e) => {
tracing::warn!("{ctx}: failed to remove {}: {e}", path.display());
return;
}
}
let wal = path.with_extension("duckdb.wal");
if wal.exists() {
if let Err(e) = fs::remove_file(&wal) {
tracing::warn!("{ctx}: failed to remove WAL {}: {e}", wal.display());
} else {
tracing::info!("{ctx}: removed WAL {}", wal.display());
}
}
}
const STALE_TMP_AGE_SECS: u64 = 3600;
pub fn cleanup_stale_tmp_files(dir: &Path) {
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if !name.contains(".duckdb.tmp") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(modified) = meta.modified() else {
continue;
};
let Ok(age) = modified.elapsed() else {
continue;
};
if age.as_secs() < STALE_TMP_AGE_SECS {
continue;
}
if let Err(e) = fs::remove_file(&path) {
tracing::warn!(
"cleanup_stale_tmp_files: failed to remove {}: {e}",
path.display()
);
} else {
tracing::info!("cleanup_stale_tmp_files: removed stale {}", path.display());
}
}
}
fn cleanup_stale_tmp_files_recursive(dir: &Path) {
cleanup_stale_tmp_files(dir);
let Ok(rd) = fs::read_dir(dir) else { return };
for entry in rd.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
cleanup_stale_tmp_files_recursive(&path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Options;
use std::path::PathBuf;
fn base_opts() -> Options {
Options {
repo_path: PathBuf::from("/tmp/test-repo"),
..Options::default()
}
}
#[test]
fn cache_key_is_stable_across_identical_inputs() {
let opts = base_opts();
let k1 = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts);
let k2 = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts);
assert_eq!(k1, k2, "key must be deterministic for identical inputs");
}
#[test]
fn cache_key_changes_when_head_sha_changes() {
let opts = base_opts();
let k1 = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts);
let k2 = cache_key(Path::new("/tmp/test-repo"), "def456", &opts);
assert_ne!(k1, k2, "key must differ when HEAD SHA differs");
}
#[test]
fn cache_key_changes_when_min_revs_changes() {
let opts_a = Options {
min_revs: 5,
..base_opts()
};
let opts_b = Options {
min_revs: 10,
..base_opts()
};
let k1 = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts_a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts_b);
assert_ne!(k1, k2, "key must differ when min_revs changes");
}
#[test]
fn cache_key_changes_when_fisher_significance_changes() {
let opts_a = Options {
fisher_significance: 0.05,
..base_opts()
};
let opts_b = Options {
fisher_significance: 0.01,
..base_opts()
};
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_b);
assert_ne!(k1, k2, "key must differ when fisher_significance changes");
}
#[test]
fn cache_key_unchanged_when_target_changes() {
let opts_no_target = Options {
window_days: 30,
..base_opts()
};
let opts_with_target = Options {
target: Some("src/lib.rs".into()),
window_days: 30,
..base_opts()
};
let opts_different_window = Options {
target: Some("src/lib.rs".into()),
window_days: 60,
..base_opts()
};
let k_no = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_no_target);
let k_with = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_with_target);
let k_diff = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_different_window);
assert_eq!(k_no, k_with, "target must not affect the cache key");
assert_ne!(
k_with, k_diff,
"window_days still differentiates the key when target differs"
);
}
#[test]
fn cache_key_does_not_change_when_rows_limit_changes() {
let opts_a = Options {
rows_limit: None,
..base_opts()
};
let opts_b = Options {
rows_limit: Some(100),
..base_opts()
};
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_b);
assert_eq!(k1, k2, "rows_limit is cosmetic and must not affect the key");
}
#[test]
fn cache_key_changes_when_min_clone_node_count_changes() {
let opts_a = Options {
min_clone_node_count: 30,
..base_opts()
};
let opts_b = Options {
min_clone_node_count: 60,
..base_opts()
};
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_b);
assert_ne!(k1, k2, "min_clone_node_count must affect the cache key");
}
#[test]
fn cache_key_changes_when_exclude_patterns_change() {
let mut a = base_opts();
a.exclude_patterns = vec!["vendor/**".into()];
let mut b = base_opts();
b.exclude_patterns = vec!["target/**".into()];
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &b);
assert_ne!(k1, k2, "exclude_patterns must affect the cache key");
}
#[test]
fn cache_key_invariant_to_exclude_pattern_order() {
let mut a = base_opts();
a.exclude_patterns = vec!["vendor/**".into(), "target/**".into()];
let mut b = base_opts();
b.exclude_patterns = vec!["target/**".into(), "vendor/**".into()];
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &b);
assert_eq!(
k1, k2,
"exclude_patterns order must not affect the key (canonical sort)"
);
}
#[test]
fn cache_key_changes_when_clone_similarity_floor_changes() {
let opts_a = Options {
clone_similarity_floor: 0.70,
..base_opts()
};
let opts_b = Options {
clone_similarity_floor: 0.85,
..base_opts()
};
let k1 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_a);
let k2 = cache_key(Path::new("/tmp/test-repo"), "sha", &opts_b);
assert_ne!(k1, k2, "clone_similarity_floor must affect the cache key");
}
#[test]
fn cache_key_changes_when_head_only_ingest_changes() {
let full = base_opts();
let head_only = Options {
head_only_ingest: true,
..base_opts()
};
let k_full = cache_key(Path::new("/tmp/test-repo"), "sha", &full);
let k_head = cache_key(Path::new("/tmp/test-repo"), "sha", &head_only);
assert_ne!(
k_full, k_head,
"head_only_ingest must key head-only stores apart from full stores"
);
}
#[test]
fn cache_path_has_correct_structure() {
let opts = base_opts();
let key = cache_key(Path::new("/tmp/test-repo"), "abc123", &opts);
let root = PathBuf::from("/tmp/xdg-cache");
let path = cache_path_with_root(&key, Path::new("/tmp/test-repo"), &root);
assert!(path.starts_with(root.join("codelore")));
assert_eq!(path.extension().and_then(|x| x.to_str()), Some("duckdb"));
let stem = path.file_stem().unwrap().to_str().unwrap();
assert_eq!(stem.len(), 16, "stem must be 16 hex chars, got: {stem}");
assert!(
stem.chars().all(|c| c.is_ascii_hexdigit()),
"stem must be all hex chars"
);
let repo_component = path
.parent()
.unwrap()
.file_name()
.unwrap()
.to_str()
.unwrap();
assert_eq!(
repo_component.len(),
8,
"repo hash must be 8 hex chars, got: {repo_component}"
);
}
#[test]
#[cfg(feature = "test-support")]
fn prune_repo_cache_keeps_newest_entries() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for i in 0..7u64 {
let path = root.join(format!("{i:016x}.duckdb"));
std::fs::write(&path, b"placeholder").unwrap();
}
prune_repo_cache(root, 5);
let remaining = std::fs::read_dir(root)
.unwrap()
.flatten()
.filter(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.is_some_and(|x| x == "duckdb")
})
.count();
assert!(
remaining <= 5,
"expected at most 5 entries after prune, got {remaining}"
);
}
}