use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use fs2::FileExt;
use url::Url;
use super::error::CacheError;
use super::meta::CacheMeta;
use super::path::{lock_path, meta_path, url_to_cache_path};
#[derive(Debug, Clone)]
pub struct CacheEntry {
pub content: String,
pub meta: CacheMeta,
pub path: PathBuf,
}
#[derive(Debug, Default)]
pub struct GcStats {
pub files_removed: usize,
pub bytes_freed: u64,
pub files_kept: usize,
}
#[derive(Debug, Clone)]
pub struct GcOptions {
pub older_than: Option<Duration>,
pub max_size: Option<u64>,
pub offline: bool,
}
impl Default for GcOptions {
fn default() -> Self {
Self {
older_than: Some(Duration::from_secs(30 * 24 * 60 * 60)), max_size: None,
offline: false,
}
}
}
pub trait CacheStorage: Send + Sync {
fn get(&self, url: &Url) -> Result<Option<CacheEntry>, CacheError>;
fn put(&self, url: &Url, content: &[u8], meta: &CacheMeta) -> Result<PathBuf, CacheError>;
fn update_last_used(&self, url: &Url) -> Result<(), CacheError>;
fn list(&self) -> Result<Vec<CacheEntry>, CacheError>;
fn remove(&self, url: &Url) -> Result<(), CacheError>;
fn gc(&self, opts: &GcOptions) -> Result<GcStats, CacheError>;
fn clean(&self) -> Result<(), CacheError>;
}
pub struct FsStorage {
cache_dir: PathBuf,
}
impl FsStorage {
pub fn new(cache_dir: PathBuf) -> Self {
Self { cache_dir }
}
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
fn lock(&self, url: &Url) -> Result<FileLock, CacheError> {
let cache_path = url_to_cache_path(url, &self.cache_dir);
let lock_file_path = lock_path(&cache_path);
if let Some(parent) = lock_file_path.parent() {
fs::create_dir_all(parent)?;
}
let lock_file = File::create(&lock_file_path)?;
if lock_file.try_lock_exclusive().is_err() {
eprintln!("Waiting for lock on schema cache...");
lock_file.lock_exclusive()?;
}
Ok(FileLock {
_file: lock_file,
path: lock_file_path,
})
}
fn try_read_cache_entry(path: &std::path::Path) -> Option<CacheEntry> {
if path.extension().and_then(|e| e.to_str()) != Some("meta") {
return None;
}
let content_path = path.with_extension("");
if !content_path.exists() {
return None;
}
let meta_content = fs::read_to_string(path).ok()?;
let meta = serde_json::from_str::<CacheMeta>(&meta_content).ok()?;
let content = fs::read_to_string(&content_path).ok()?;
Some(CacheEntry {
content,
meta,
path: content_path,
})
}
}
struct FileLock {
_file: File,
path: PathBuf,
}
impl Drop for FileLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
impl CacheStorage for FsStorage {
fn get(&self, url: &Url) -> Result<Option<CacheEntry>, CacheError> {
let cache_path = url_to_cache_path(url, &self.cache_dir);
let meta_file_path = meta_path(&cache_path);
if !cache_path.exists() || !meta_file_path.exists() {
return Ok(None);
}
let meta_content = fs::read_to_string(&meta_file_path)?;
let meta: CacheMeta = serde_json::from_str(&meta_content)?;
let content = fs::read_to_string(&cache_path)?;
Ok(Some(CacheEntry {
content,
meta,
path: cache_path,
}))
}
fn put(&self, url: &Url, content: &[u8], meta: &CacheMeta) -> Result<PathBuf, CacheError> {
let _lock = self.lock(url)?;
let cache_path = url_to_cache_path(url, &self.cache_dir);
let meta_file_path = meta_path(&cache_path);
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let dir = cache_path.parent().unwrap();
let mut temp_content = tempfile::NamedTempFile::new_in(dir)?;
temp_content.write_all(content)?;
temp_content.persist(&cache_path)?;
let mut temp_meta = tempfile::NamedTempFile::new_in(dir)?;
serde_json::to_writer_pretty(&mut temp_meta, meta)?;
temp_meta.persist(&meta_file_path)?;
Ok(cache_path)
}
fn update_last_used(&self, url: &Url) -> Result<(), CacheError> {
let cache_path = url_to_cache_path(url, &self.cache_dir);
let meta_file_path = meta_path(&cache_path);
if !meta_file_path.exists() {
return Ok(());
}
let meta_content = fs::read_to_string(&meta_file_path)?;
let mut meta: CacheMeta = serde_json::from_str(&meta_content)?;
meta.touch();
let dir = meta_file_path.parent().unwrap();
let mut temp = tempfile::NamedTempFile::new_in(dir)?;
serde_json::to_writer_pretty(&mut temp, &meta)?;
temp.persist(&meta_file_path)?;
Ok(())
}
fn list(&self) -> Result<Vec<CacheEntry>, CacheError> {
let mut entries = Vec::new();
if !self.cache_dir.exists() {
return Ok(entries);
}
for entry in walkdir::WalkDir::new(&self.cache_dir)
.into_iter()
.filter_map(|e| e.ok())
{
if let Some(cache_entry) = Self::try_read_cache_entry(entry.path()) {
entries.push(cache_entry);
}
}
Ok(entries)
}
fn remove(&self, url: &Url) -> Result<(), CacheError> {
let _lock = self.lock(url)?;
let cache_path = url_to_cache_path(url, &self.cache_dir);
let meta_file_path = meta_path(&cache_path);
if cache_path.exists() {
fs::remove_file(&cache_path)?;
}
if meta_file_path.exists() {
fs::remove_file(&meta_file_path)?;
}
Ok(())
}
fn gc(&self, opts: &GcOptions) -> Result<GcStats, CacheError> {
if opts.offline {
return Ok(GcStats::default());
}
let mut stats = GcStats::default();
let mut entries = self.list()?;
entries.sort_by_key(|a| a.meta.last_used_at);
let now = chrono::Utc::now();
let mut total_size: u64 = entries.iter().map(|e| e.meta.size_bytes).sum();
for entry in entries {
let should_remove = if let Some(older_than) = opts.older_than {
let age = now.signed_duration_since(entry.meta.last_used_at);
age > chrono::Duration::from_std(older_than).unwrap_or(chrono::TimeDelta::MAX)
} else if let Some(max_size) = opts.max_size {
total_size > max_size
} else {
false
};
if should_remove {
let cache_path = &entry.path;
let lock_file_path = lock_path(cache_path);
if let Ok(lock_file) = File::create(&lock_file_path) {
if lock_file.try_lock_exclusive().is_ok() {
let meta_file_path = meta_path(cache_path);
if let Ok(()) = fs::remove_file(cache_path) {
let _ = fs::remove_file(&meta_file_path);
stats.files_removed += 1;
stats.bytes_freed += entry.meta.size_bytes;
total_size -= entry.meta.size_bytes;
}
let _ = fs::remove_file(&lock_file_path);
} else {
stats.files_kept += 1;
}
} else {
stats.files_kept += 1;
}
} else {
stats.files_kept += 1;
}
}
Ok(stats)
}
fn clean(&self) -> Result<(), CacheError> {
if self.cache_dir.exists() {
fs::remove_dir_all(&self.cache_dir)?;
}
Ok(())
}
}