use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use qli_core::cache::CacheKey;
use qli_core::AnalysisResult;
pub const DEFAULT_SIZE_CAP_BYTES: u64 = 500 * 1024 * 1024;
pub const DEFAULT_TTL_DAYS: u64 = 30;
#[derive(Debug)]
pub struct AnalysisCache {
root: PathBuf,
enabled: bool,
size_cap: u64,
}
impl AnalysisCache {
#[must_use]
pub fn new(root: PathBuf, enabled: bool) -> Self {
Self {
root,
enabled,
size_cap: DEFAULT_SIZE_CAP_BYTES,
}
}
#[must_use]
pub fn get(&self, key: &CacheKey) -> Option<AnalysisResult> {
if !self.enabled {
return None;
}
let path = self.root.join(key.relative_path());
let bytes = std::fs::read(&path).ok()?;
let result = serde_json::from_slice(&bytes).ok()?;
let _ = filetime_now(&path);
Some(result)
}
pub fn put(&self, key: &CacheKey, result: &AnalysisResult) -> std::io::Result<()> {
if !self.enabled {
return Ok(());
}
let path = self.root.join(key.relative_path());
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let bytes = serde_json::to_vec(result)?;
std::fs::write(&path, bytes)?;
Ok(())
}
pub fn clean(&self, all: bool, older_than: Option<Duration>) -> (u64, u64) {
if all {
return self.remove_matching(|_| true);
}
let ttl = older_than.unwrap_or(Duration::from_secs(DEFAULT_TTL_DAYS * 86_400));
let cutoff = SystemTime::now().checked_sub(ttl);
let removed = self.remove_matching(|mtime| match cutoff {
Some(cutoff) => mtime < cutoff,
None => false,
});
if older_than.is_none() {
self.enforce_size_cap();
}
removed
}
pub fn enforce_size_cap(&self) {
if !self.enabled {
return;
}
let mut entries = self.entries();
let mut total: u64 = entries.iter().map(|e| e.size).sum();
if total <= self.size_cap {
return;
}
entries.sort_by_key(|e| e.mtime);
for entry in entries {
if total <= self.size_cap {
break;
}
if std::fs::remove_file(&entry.path).is_ok() {
total = total.saturating_sub(entry.size);
}
}
}
fn remove_matching(&self, pred: impl Fn(SystemTime) -> bool) -> (u64, u64) {
let mut files = 0;
let mut bytes = 0;
for entry in self.entries() {
if pred(entry.mtime) && std::fs::remove_file(&entry.path).is_ok() {
files += 1;
bytes += entry.size;
}
}
(files, bytes)
}
fn entries(&self) -> Vec<CacheEntry> {
let mut out = Vec::new();
collect_entries(&self.root, &mut out);
out
}
}
struct CacheEntry {
path: PathBuf,
size: u64,
mtime: SystemTime,
}
fn collect_entries(dir: &Path, out: &mut Vec<CacheEntry>) {
let Ok(read) = std::fs::read_dir(dir) else {
return;
};
for entry in read.flatten() {
let path = entry.path();
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
collect_entries(&path, out);
} else if path.extension().is_some_and(|e| e == "json") {
out.push(CacheEntry {
path,
size: meta.len(),
mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
});
}
}
}
fn filetime_now(path: &Path) -> std::io::Result<()> {
let file = std::fs::File::options().write(true).open(path)?;
file.set_modified(SystemTime::now())
}
#[cfg(test)]
mod tests {
use super::*;
use qli_core::{Diagnostic, LanguageId, Position, Range, Severity, Source};
use tempfile::TempDir;
fn source(content: &[u8]) -> Source {
Source::new("a.py".into(), content.to_vec(), LanguageId::from("python"))
}
fn result() -> AnalysisResult {
AnalysisResult {
diagnostics: vec![Diagnostic::new(
Range::new(Position::new(0, 0), Position::new(0, 4)),
Severity::Info,
"TODO",
)],
..Default::default()
}
}
#[test]
fn put_then_get_roundtrips() {
let tmp = TempDir::new().unwrap();
let cache = AnalysisCache::new(tmp.path().join("analysis"), true);
let source_key = source(b"x");
let key = CacheKey::new(&source_key, "f", "todofixme", 1);
assert!(cache.get(&key).is_none(), "cold miss");
cache.put(&key, &result()).unwrap();
assert_eq!(cache.get(&key), Some(result()), "warm hit");
}
#[test]
fn version_bump_invalidates_without_touching_old() {
let tmp = TempDir::new().unwrap();
let cache = AnalysisCache::new(tmp.path().join("analysis"), true);
let source_v1 = source(b"x");
let v1 = CacheKey::new(&source_v1, "f", "todofixme", 1);
cache.put(&v1, &result()).unwrap();
let source_v2 = source(b"x");
let v2 = CacheKey::new(&source_v2, "f", "todofixme", 2);
assert!(cache.get(&v2).is_none(), "bumped version is a miss");
assert!(cache.get(&v1).is_some(), "old entry untouched on disk");
}
#[test]
fn content_change_invalidates() {
let tmp = TempDir::new().unwrap();
let cache = AnalysisCache::new(tmp.path().join("analysis"), true);
cache
.put(
&CacheKey::new(&source(b"x"), "f", "todofixme", 1),
&result(),
)
.unwrap();
assert!(cache
.get(&CacheKey::new(&source(b"y"), "f", "todofixme", 1))
.is_none());
}
#[test]
fn disabled_cache_never_stores() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().join("analysis");
let cache = AnalysisCache::new(root.clone(), false);
let source_key = source(b"x");
let key = CacheKey::new(&source_key, "f", "todofixme", 1);
cache.put(&key, &result()).unwrap();
assert!(cache.get(&key).is_none());
assert!(!root.exists(), "disabled cache writes nothing");
}
#[test]
fn clean_all_empties_the_cache() {
let tmp = TempDir::new().unwrap();
let cache = AnalysisCache::new(tmp.path().join("analysis"), true);
cache
.put(
&CacheKey::new(&source(b"a"), "f", "todofixme", 1),
&result(),
)
.unwrap();
cache
.put(
&CacheKey::new(&source(b"b"), "f", "todofixme", 1),
&result(),
)
.unwrap();
let (files, _) = cache.clean(true, None);
assert_eq!(files, 2);
assert!(cache
.get(&CacheKey::new(&source(b"a"), "f", "todofixme", 1))
.is_none());
}
}