use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{Result, StorageError};
#[derive(Debug, Clone)]
struct CacheEntry {
size: u64,
last_access: u64,
}
pub struct FileCache {
dir: PathBuf,
max_size: u64,
entries: BTreeMap<String, CacheEntry>,
current_size: u64,
}
impl FileCache {
pub fn new(dir: &Path, max_size: u64) -> Result<Self> {
std::fs::create_dir_all(dir)
.map_err(|e| StorageError::Cache(format!("create cache dir failed: {e}")))?;
Ok(Self {
dir: dir.to_path_buf(),
max_size,
entries: BTreeMap::new(),
current_size: 0,
})
}
pub fn get(&mut self, cid: &str) -> Option<Vec<u8>> {
if !self.entries.contains_key(cid) {
return None;
}
let path = self.path_for(cid);
match std::fs::read(&path) {
Ok(data) => {
if let Some(entry) = self.entries.get_mut(cid) {
entry.last_access = now_secs();
}
Some(data)
}
Err(_) => {
self.entries.remove(cid);
None
}
}
}
pub fn put(&mut self, cid: &str, data: &[u8]) -> Result<()> {
let size = data.len() as u64;
if size > self.max_size {
return Ok(());
}
while self.current_size + size > self.max_size {
if !self.evict_oldest() {
break;
}
}
let path = self.path_for(cid);
std::fs::write(&path, data)
.map_err(|e| StorageError::Cache(format!("write cache file failed: {e}")))?;
self.current_size += size;
self.entries.insert(
cid.to_string(),
CacheEntry {
size,
last_access: now_secs(),
},
);
Ok(())
}
pub fn remove(&mut self, cid: &str) -> Result<()> {
if let Some(entry) = self.entries.remove(cid) {
self.current_size = self.current_size.saturating_sub(entry.size);
let path = self.path_for(cid);
let _ = std::fs::remove_file(path);
}
Ok(())
}
pub fn contains(&self, cid: &str) -> bool {
self.entries.contains_key(cid)
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn size(&self) -> u64 {
self.current_size
}
pub fn clear(&mut self) -> Result<()> {
for cid in self.entries.keys().cloned().collect::<Vec<_>>() {
let path = self.path_for(&cid);
let _ = std::fs::remove_file(path);
}
self.entries.clear();
self.current_size = 0;
Ok(())
}
fn path_for(&self, cid: &str) -> PathBuf {
let safe_name: String = cid.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect();
self.dir.join(safe_name)
}
fn evict_oldest(&mut self) -> bool {
let oldest_cid = self
.entries
.iter()
.min_by_key(|(_, e)| e.last_access)
.map(|(k, _)| k.clone());
if let Some(cid) = oldest_cid {
if let Some(entry) = self.entries.remove(&cid) {
self.current_size = self.current_size.saturating_sub(entry.size);
let path = self.path_for(&cid);
let _ = std::fs::remove_file(path);
return true;
}
}
false
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_cache(max_size: u64) -> (FileCache, TempDir) {
let tmp = TempDir::new().unwrap();
let cache = FileCache::new(tmp.path(), max_size).unwrap();
(cache, tmp)
}
#[test]
fn put_and_get() {
let (mut cache, _tmp) = test_cache(1_000_000);
cache.put("QmTest1", b"hello world").unwrap();
assert_eq!(cache.get("QmTest1").unwrap(), b"hello world");
}
#[test]
fn miss_returns_none() {
let (mut cache, _tmp) = test_cache(1_000_000);
assert!(cache.get("QmNope").is_none());
}
#[test]
fn contains_check() {
let (mut cache, _tmp) = test_cache(1_000_000);
assert!(!cache.contains("Qm1"));
cache.put("Qm1", b"data").unwrap();
assert!(cache.contains("Qm1"));
}
#[test]
fn remove_entry() {
let (mut cache, _tmp) = test_cache(1_000_000);
cache.put("Qm1", b"data").unwrap();
cache.remove("Qm1").unwrap();
assert!(!cache.contains("Qm1"));
assert!(cache.is_empty());
}
#[test]
fn eviction_when_full() {
let (mut cache, _tmp) = test_cache(50);
cache.put("Qm1", &[0u8; 30]).unwrap();
cache.put("Qm2", &[1u8; 30]).unwrap();
assert!(!cache.contains("Qm1")); assert!(cache.contains("Qm2"));
}
#[test]
fn skip_oversized() {
let (mut cache, _tmp) = test_cache(10);
cache.put("QmBig", &[0u8; 100]).unwrap(); assert!(!cache.contains("QmBig"));
}
#[test]
fn clear_all() {
let (mut cache, _tmp) = test_cache(1_000_000);
cache.put("Qm1", b"a").unwrap();
cache.put("Qm2", b"b").unwrap();
cache.clear().unwrap();
assert!(cache.is_empty());
assert_eq!(cache.size(), 0);
}
#[test]
fn size_tracking() {
let (mut cache, _tmp) = test_cache(1_000_000);
cache.put("Qm1", &[0u8; 100]).unwrap();
cache.put("Qm2", &[0u8; 200]).unwrap();
assert_eq!(cache.size(), 300);
cache.remove("Qm1").unwrap();
assert_eq!(cache.size(), 200);
}
}