use anyhow::Result;
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::io::{BufReader, Read};
use std::path::Path;
use std::time::SystemTime;
use super::types::{CacheEntry, CacheKey, CacheMetadata};
#[derive(Debug)]
pub struct FileCache {
cache_dir: std::path::PathBuf,
}
impl FileCache {
pub fn new(cache_dir: std::path::PathBuf) -> Result<Self> {
fs::create_dir_all(&cache_dir)?;
Ok(Self { cache_dir })
}
pub fn hash_file(path: &Path) -> Result<String> {
let file = File::open(path)?;
let mut reader = BufReader::with_capacity(65536, file); let mut hasher = Sha256::new();
let mut buffer = [0u8; 65536];
loop {
let bytes_read = reader.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
let result = hasher.finalize();
Ok(format!("{:x}", result))
}
pub fn generate_key(path: &Path) -> Result<CacheKey> {
let file_hash = Self::hash_file(path)?;
Ok(CacheKey {
file_path: path.to_path_buf(),
file_hash,
})
}
pub fn save<T>(&self, key: &CacheKey, data: &T) -> Result<()>
where
T: serde::Serialize,
{
let serialized = bincode::serialize(data)?;
let original_size = serialized.len();
let compressed = lz4::block::compress(&serialized, None, true)?;
let compressed_size = compressed.len();
let metadata = CacheMetadata {
created_at: SystemTime::now(),
last_accessed: SystemTime::now(),
file_size: original_size as u64,
compressed_size,
compression_ratio: original_size as f32 / compressed_size as f32,
};
let entry = CacheEntry {
key: key.clone(),
data: compressed,
metadata,
};
let cache_path = self.cache_path(key);
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
let entry_data = bincode::serialize(&entry)?;
fs::write(cache_path, entry_data)?;
Ok(())
}
pub fn load<T>(&self, key: &CacheKey) -> Result<Option<T>>
where
T: serde::de::DeserializeOwned,
{
let cache_path = self.cache_path(key);
if !cache_path.exists() {
return Ok(None);
}
let entry_data = fs::read(&cache_path)?;
let mut entry: CacheEntry<Vec<u8>> = bincode::deserialize(&entry_data)?;
entry.metadata.last_accessed = SystemTime::now();
let decompressed =
lz4::block::decompress(&entry.data, Some(entry.metadata.file_size as i32))?;
let data: T = bincode::deserialize(&decompressed)?;
Ok(Some(data))
}
pub fn is_valid(&self, key: &CacheKey) -> Result<bool> {
if !key.file_path.exists() {
return Ok(false);
}
let current_hash = Self::hash_file(&key.file_path)?;
Ok(current_hash == key.file_hash)
}
pub fn remove(&self, key: &CacheKey) -> Result<()> {
let cache_path = self.cache_path(key);
if cache_path.exists() {
fs::remove_file(cache_path)?;
}
Ok(())
}
fn cache_path(&self, key: &CacheKey) -> std::path::PathBuf {
let hash_prefix = &key.file_hash[..2];
let cache_name = format!(
"{}_{}.cache",
key.file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown"),
&key.file_hash[..8]
);
self.cache_dir.join(hash_prefix).join(cache_name)
}
pub fn get_stats(&self) -> Result<CacheStats> {
let mut total_entries = 0;
let mut total_size = 0;
let mut total_compressed_size = 0;
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.path().is_dir() {
for cache_file in fs::read_dir(entry.path())? {
let cache_file = cache_file?;
let file_metadata = cache_file.metadata()?;
total_entries += 1;
total_compressed_size += file_metadata.len() as usize;
if let Ok(entry_data) = fs::read(cache_file.path()) {
if let Ok(entry) = bincode::deserialize::<CacheEntry<Vec<u8>>>(&entry_data) {
total_size += entry.metadata.file_size as usize;
continue;
}
}
total_size += file_metadata.len() as usize;
}
}
}
Ok(CacheStats {
total_entries,
total_size,
total_compressed_size,
compression_ratio: if total_compressed_size > 0 {
total_size as f32 / total_compressed_size as f32
} else {
1.0
},
cache_dir: self.cache_dir.clone(),
})
}
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub total_entries: usize,
pub total_size: usize,
pub total_compressed_size: usize,
pub compression_ratio: f32,
pub cache_dir: std::path::PathBuf,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_key_structure() {
let path = Path::new("src/main.rs");
let file_hash = "abc123def456".to_string();
let key = CacheKey {
file_path: path.to_path_buf(),
file_hash: file_hash.clone(),
};
assert_eq!(key.file_hash, "abc123def456");
assert_eq!(key.file_path.file_name().unwrap(), "main.rs");
}
#[test]
fn test_cache_metadata_structure() {
let now = SystemTime::now();
let metadata = CacheMetadata {
created_at: now,
last_accessed: now,
file_size: 1024,
compressed_size: 512,
compression_ratio: 2.0,
};
assert_eq!(metadata.file_size, 1024);
assert_eq!(metadata.compressed_size, 512);
assert_eq!(metadata.compression_ratio, 2.0);
}
#[test]
fn test_cache_entry_structure() {
let key = CacheKey {
file_path: Path::new("test.rs").to_path_buf(),
file_hash: "test_hash".to_string(),
};
let metadata = CacheMetadata {
created_at: SystemTime::now(),
last_accessed: SystemTime::now(),
file_size: 100,
compressed_size: 50,
compression_ratio: 2.0,
};
let entry = CacheEntry {
key: key.clone(),
data: vec![1, 2, 3, 4, 5],
metadata,
};
assert_eq!(entry.data.len(), 5);
assert_eq!(entry.key.file_hash, "test_hash");
}
#[test]
fn test_cache_stats_structure() {
let stats = CacheStats {
total_entries: 100,
total_size: 1_000_000,
total_compressed_size: 500_000,
compression_ratio: 2.0,
cache_dir: Path::new("/cache").to_path_buf(),
};
assert_eq!(stats.total_entries, 100);
assert_eq!(stats.total_size, 1_000_000);
assert_eq!(stats.compression_ratio, 2.0);
}
#[test]
fn test_compression_ratio_calculation() {
let test_cases = vec![
(1000, 500, 2.0),
(2000, 1000, 2.0),
(3000, 1000, 3.0),
(1000, 250, 4.0),
];
for (original, compressed, expected) in test_cases {
let ratio = original as f32 / compressed as f32;
assert!((ratio - expected).abs() < 0.01);
}
}
#[test]
fn test_cache_path_construction() {
let hash = "abc123def456";
let prefix = &hash[..2];
assert_eq!(prefix, "ab", "Prefix should be first 2 chars of hash");
}
#[test]
fn test_cache_file_naming() {
let file_name = "main.rs";
let hash_short = "abc12345";
let cache_name = format!("{}_{}.cache", file_name, hash_short);
assert!(
cache_name.contains("main.rs"),
"Should include original filename"
);
assert!(cache_name.contains("abc12345"), "Should include hash short");
assert!(cache_name.ends_with(".cache"), "Should end with .cache");
}
#[test]
fn test_cache_stats_compression_ratio_zero_handling() {
let total_size = 1000;
let compressed_size = 0;
let ratio = if compressed_size > 0 {
total_size as f32 / compressed_size as f32
} else {
1.0 };
assert_eq!(
ratio, 1.0,
"Should default to 1.0 when compressed size is 0"
);
}
}