use std::collections::HashMap;
use std::path::Path;
use crate::core::{CallEdge, CodeNode, NodeId};
use serde::{Deserialize, Serialize};
use tracing::warn;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileEntry {
pub path: String,
pub content_hash: u64,
pub nodes: Vec<CodeNode>,
pub edges: Vec<CallEdge>,
pub entry_points: Vec<NodeId>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistentCache {
entries: HashMap<String, CachedFileEntry>,
}
impl PersistentCache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
}
}
pub fn load(cache_path: &Path) -> Self {
if !cache_path.exists() {
return Self::new();
}
match std::fs::read_to_string(cache_path) {
Ok(contents) => match serde_json::from_str::<PersistentCache>(&contents) {
Ok(cache) => cache,
Err(e) => {
warn!("Failed to parse cache file {}: {}", cache_path.display(), e);
Self::new()
}
},
Err(e) => {
warn!("Failed to read cache file {}: {}", cache_path.display(), e);
Self::new()
}
}
}
pub fn save(&self, cache_path: &Path) -> Result<(), crate::core::Error> {
if let Some(parent) = cache_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::core::Error::analysis(format!(
"Failed to create cache directory {}: {}",
parent.display(),
e
))
})?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| crate::core::Error::analysis(format!("Failed to serialize cache: {e}")))?;
std::fs::write(cache_path, json).map_err(|e| {
crate::core::Error::analysis(format!(
"Failed to write cache file {}: {}",
cache_path.display(),
e
))
})?;
Ok(())
}
pub fn is_file_changed(&self, path: &str, content_hash: u64) -> bool {
match self.entries.get(path) {
Some(entry) => entry.content_hash != content_hash,
None => true,
}
}
pub fn get_entry(&self, path: &str) -> Option<&CachedFileEntry> {
self.entries.get(path)
}
pub fn update_entry(&mut self, entry: CachedFileEntry) {
self.entries.insert(entry.path.clone(), entry);
}
pub fn remove_entry(&mut self, path: &str) {
self.entries.remove(path);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn entries(&self) -> impl Iterator<Item = (&String, &CachedFileEntry)> {
self.entries.iter()
}
}
impl Default for PersistentCache {
fn default() -> Self {
Self::new()
}
}
pub fn hash_content(content: &[u8]) -> u64 {
xxhash_rust::xxh3::xxh3_64(content)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{Language, NodeKind, SourceLocation, Visibility};
use tempfile::TempDir;
fn make_test_entry(path: &str, hash: u64) -> CachedFileEntry {
let loc = SourceLocation::new(path.to_string(), 1, 5, 0, 0);
let node = CodeNode::new(
"test_fn".to_string(),
NodeKind::Function,
loc,
Language::Python,
Visibility::Public,
);
let node_id = node.id;
CachedFileEntry {
path: path.to_string(),
content_hash: hash,
nodes: vec![node],
edges: vec![],
entry_points: vec![node_id],
}
}
#[test]
fn test_cache_save_load_roundtrip() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join(".fossil").join("cache.json");
let mut cache = PersistentCache::new();
cache.update_entry(make_test_entry("src/main.py", 12345));
cache.update_entry(make_test_entry("src/helper.py", 67890));
cache.save(&cache_path).unwrap();
assert!(cache_path.exists());
let loaded = PersistentCache::load(&cache_path);
assert_eq!(loaded.len(), 2);
let entry = loaded.get_entry("src/main.py").unwrap();
assert_eq!(entry.content_hash, 12345);
assert_eq!(entry.nodes.len(), 1);
assert_eq!(entry.nodes[0].name, "test_fn");
assert_eq!(entry.entry_points.len(), 1);
let entry2 = loaded.get_entry("src/helper.py").unwrap();
assert_eq!(entry2.content_hash, 67890);
}
#[test]
fn test_load_nonexistent_returns_empty() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("nonexistent").join("cache.json");
let cache = PersistentCache::load(&cache_path);
assert!(cache.is_empty());
}
#[test]
fn test_load_corrupt_returns_empty() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("cache.json");
std::fs::write(&cache_path, "not valid json").unwrap();
let cache = PersistentCache::load(&cache_path);
assert!(cache.is_empty());
}
#[test]
fn test_is_file_changed() {
let mut cache = PersistentCache::new();
cache.update_entry(make_test_entry("a.py", 100));
assert!(!cache.is_file_changed("a.py", 100));
assert!(cache.is_file_changed("a.py", 999));
assert!(cache.is_file_changed("unknown.py", 100));
}
#[test]
fn test_update_entry_overwrites() {
let mut cache = PersistentCache::new();
cache.update_entry(make_test_entry("a.py", 100));
assert_eq!(cache.get_entry("a.py").unwrap().content_hash, 100);
cache.update_entry(make_test_entry("a.py", 200));
assert_eq!(cache.get_entry("a.py").unwrap().content_hash, 200);
assert_eq!(cache.len(), 1);
}
#[test]
fn test_remove_entry() {
let mut cache = PersistentCache::new();
cache.update_entry(make_test_entry("a.py", 100));
assert_eq!(cache.len(), 1);
cache.remove_entry("a.py");
assert!(cache.is_empty());
assert!(cache.get_entry("a.py").is_none());
}
#[test]
fn test_hash_content_deterministic() {
let data = b"def main(): pass";
let h1 = hash_content(data);
let h2 = hash_content(data);
assert_eq!(h1, h2);
let h3 = hash_content(b"def main(): return 42");
assert_ne!(h1, h3);
}
}