use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use xxhash_rust::xxh3::xxh3_64;
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub hash: u64,
pub modified_at: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct FileCache {
pub version: String,
pub files: HashMap<String, CacheEntry>,
}
impl FileCache {
pub fn new() -> Self {
Self {
version: "1.0.0".to_string(),
files: HashMap::new(),
}
}
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
tracing::debug!("Cache file not found, creating new cache");
return Ok(Self::new());
}
let content = fs::read_to_string(path)?;
let cache: FileCache = serde_json::from_str(&content)?;
tracing::debug!("Loaded cache with {} entries", cache.files.len());
Ok(cache)
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(self)?;
fs::write(path, content)?;
tracing::debug!(
"Saved cache with {} entries to {}",
self.files.len(),
path.display()
);
Ok(())
}
pub fn has_changed(&self, path: &Path) -> Result<bool> {
let path_str = path.to_string_lossy().to_string();
let Some(cached) = self.files.get(&path_str) else {
return Ok(true);
};
let current_hash = Self::hash_file(path)?;
Ok(cached.hash != current_hash)
}
pub fn update(&mut self, path: &Path) -> Result<()> {
let path_str = path.to_string_lossy().to_string();
let hash = Self::hash_file(path)?;
let metadata = fs::metadata(path)?;
let entry = CacheEntry {
hash,
modified_at: chrono::Utc::now().to_rfc3339(),
size: metadata.len(),
};
self.files.insert(path_str, entry);
Ok(())
}
fn hash_file(path: &Path) -> Result<u64> {
let content = fs::read(path)?;
Ok(xxh3_64(&content))
}
pub fn prune(&mut self, existing_files: &[PathBuf]) {
let existing: std::collections::HashSet<String> = existing_files
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
self.files.retain(|path, _| existing.contains(path));
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_cache_creation() {
let cache = FileCache::new();
assert_eq!(cache.version, "1.0.0");
assert_eq!(cache.files.len(), 0);
}
#[test]
fn test_hash_file() -> Result<()> {
let mut file = NamedTempFile::new()?;
file.write_all(b"test content")?;
let hash1 = FileCache::hash_file(file.path())?;
let hash2 = FileCache::hash_file(file.path())?;
assert_eq!(hash1, hash2);
Ok(())
}
#[test]
fn test_cache_save_load() -> Result<()> {
let temp_dir = tempfile::tempdir()?;
let cache_path = temp_dir.path().join("cache.json");
let mut cache = FileCache::new();
cache.files.insert(
"test.py".to_string(),
CacheEntry {
hash: 12345,
modified_at: chrono::Utc::now().to_rfc3339(),
size: 100,
},
);
cache.save(&cache_path)?;
let loaded = FileCache::load(&cache_path)?;
assert_eq!(loaded.files.len(), 1);
assert_eq!(loaded.files.get("test.py").unwrap().hash, 12345);
Ok(())
}
#[test]
fn test_has_changed() -> Result<()> {
let mut file = NamedTempFile::new()?;
file.write_all(b"original content")?;
let mut cache = FileCache::new();
cache.update(file.path())?;
assert!(!cache.has_changed(file.path())?);
file.write_all(b" modified")?;
assert!(cache.has_changed(file.path())?);
Ok(())
}
}