use std::collections::HashMap;
use std::fs;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use anyhow::{Context, Result};
use xxhash_rust::xxh3::xxh3_64;
use unfault_core::semantics::SourceSemantics;
const CACHE_VERSION: u32 = 5;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CacheMeta {
version: u32,
created_at: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct IndexEntry {
filename: String,
content_hash: u64,
mtime_secs: u64,
file_size: u64,
}
#[derive(Debug, Clone)]
struct CacheEntry {
cache_path: PathBuf,
content_hash: u64,
mtime_secs: u64,
file_size: u64,
}
#[derive(Debug, Default)]
pub struct CacheStats {
pub hits: AtomicUsize,
pub misses: AtomicUsize,
}
impl CacheStats {
pub fn hit_rate(&self) -> f64 {
let h = self.hits.load(Ordering::Relaxed);
let m = self.misses.load(Ordering::Relaxed);
let total = h + m;
if total == 0 {
0.0
} else {
(h as f64 / total as f64) * 100.0
}
}
pub fn snapshot(&self) -> CacheStatsSnapshot {
CacheStatsSnapshot {
hits: self.hits.load(Ordering::Relaxed),
misses: self.misses.load(Ordering::Relaxed),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct CacheStatsSnapshot {
pub hits: usize,
pub misses: usize,
}
impl CacheStatsSnapshot {
pub fn hit_rate(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
(self.hits as f64 / total as f64) * 100.0
}
}
}
#[derive(Debug)]
pub struct SemanticsCache {
cache_dir: PathBuf,
index: HashMap<u64, CacheEntry>,
pub stats: CacheStats,
enabled: bool,
}
impl SemanticsCache {
fn index_path(cache_dir: &Path) -> PathBuf {
cache_dir.join("index.msgpack")
}
fn load_index(cache_dir: &Path) -> Option<HashMap<u64, CacheEntry>> {
let path = Self::index_path(cache_dir);
let file = fs::File::open(&path).ok()?;
let entries: Vec<IndexEntry> = rmp_serde::from_read(BufReader::new(file)).ok()?;
let mut map = HashMap::with_capacity(entries.len());
for e in entries {
let stem = e.filename.strip_suffix(".msgpack").unwrap_or(&e.filename);
let parts: Vec<&str> = stem.splitn(5, '_').collect();
if parts.len() >= 2 {
if let Ok(path_hash) = u64::from_str_radix(parts[1], 16) {
map.insert(
path_hash,
CacheEntry {
cache_path: cache_dir.join(&e.filename),
content_hash: e.content_hash,
mtime_secs: e.mtime_secs,
file_size: e.file_size,
},
);
}
}
}
Some(map)
}
fn save_index(&self) {
let entries: Vec<IndexEntry> = self
.index
.values()
.filter_map(|e| {
e.cache_path
.file_name()
.and_then(|n| n.to_str())
.map(|filename| IndexEntry {
filename: filename.to_string(),
content_hash: e.content_hash,
mtime_secs: e.mtime_secs,
file_size: e.file_size,
})
})
.collect();
let path = Self::index_path(&self.cache_dir);
if let Ok(file) = fs::File::create(&path) {
let mut writer = BufWriter::new(file);
let _ = rmp_serde::encode::write(&mut writer, &entries);
}
}
pub fn open(workspace_path: &Path) -> Result<Self> {
let cache_dir = workspace_path
.join(".unfault")
.join("cache")
.join("semantics");
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir).context("Failed to create cache directory")?;
}
let meta_path = cache_dir.join("meta.json");
let should_clear = if meta_path.exists() {
let meta: CacheMeta = serde_json::from_reader(BufReader::new(fs::File::open(
&meta_path,
)?))
.unwrap_or(CacheMeta {
version: 0,
created_at: 0,
});
meta.version != CACHE_VERSION
} else {
false
};
if should_clear {
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext == "msgpack" || path == meta_path {
let _ = fs::remove_file(&path);
}
}
}
let meta = CacheMeta {
version: CACHE_VERSION,
created_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
};
serde_json::to_writer(BufWriter::new(fs::File::create(&meta_path)?), &meta)?;
let (index, needs_persist) = match Self::load_index(&cache_dir) {
Some(map) => (map, false),
None => {
let mut map = HashMap::new();
if let Ok(read_dir) = fs::read_dir(&cache_dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().map_or(false, |e| e == "msgpack") {
if path.file_name().map_or(false, |n| n == "index.msgpack") {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
let parts: Vec<&str> = filename.splitn(5, '_').collect();
if parts.len() >= 2 {
if let (Ok(content_hash), Ok(path_hash)) = (
u64::from_str_radix(parts[0], 16),
u64::from_str_radix(parts[1], 16),
) {
let mtime_secs = parts
.get(2)
.and_then(|s| u64::from_str_radix(s, 16).ok())
.unwrap_or(0);
let file_size = parts
.get(3)
.and_then(|s| u64::from_str_radix(s, 16).ok())
.unwrap_or(0);
map.insert(
path_hash,
CacheEntry {
cache_path: path.clone(),
content_hash,
mtime_secs,
file_size,
},
);
}
}
}
}
}
(map, true)
}
};
let cache = Self {
cache_dir,
index,
stats: CacheStats::default(),
enabled: true,
};
if needs_persist {
cache.save_index();
}
Ok(cache)
}
pub fn disabled() -> Self {
Self {
cache_dir: PathBuf::new(),
index: HashMap::new(),
stats: CacheStats::default(),
enabled: false,
}
}
pub fn hash_content(content: &str) -> u64 {
xxh3_64(content.as_bytes())
}
pub fn check_metadata(
&self,
relative_path: &str,
mtime_secs: u64,
file_size: u64,
) -> Option<(u64, PathBuf)> {
if !self.enabled {
return None;
}
let path_hash = xxh3_64(relative_path.as_bytes());
let entry = self.index.get(&path_hash)?;
if entry.mtime_secs == 0
|| entry.file_size == 0
|| entry.mtime_secs != mtime_secs
|| entry.file_size != file_size
{
return None;
}
Some((entry.content_hash, entry.cache_path.clone()))
}
pub fn get(&self, relative_path: &str, content_hash: u64) -> Option<SourceSemantics> {
if !self.enabled {
return None;
}
let path_hash = xxh3_64(relative_path.as_bytes());
let entry = match self.index.get(&path_hash) {
Some(e) => e,
None => {
self.stats.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
};
if entry.content_hash != content_hash {
self.stats.misses.fetch_add(1, Ordering::Relaxed);
return None;
}
let file = fs::File::open(&entry.cache_path).ok()?;
match rmp_serde::from_read(BufReader::new(file)) {
Ok(semantics) => {
self.stats.hits.fetch_add(1, Ordering::Relaxed);
Some(semantics)
}
Err(_) => {
self.stats.misses.fetch_add(1, Ordering::Relaxed);
None
}
}
}
pub fn get_stored_content_hash(&self, path_hash: u64) -> Option<u64> {
self.index.get(&path_hash).map(|e| e.content_hash)
}
pub fn record_metadata_hit(&self) {
self.stats.hits.fetch_add(1, Ordering::Relaxed);
}
pub fn record_miss(&self) {
self.stats.misses.fetch_add(1, Ordering::Relaxed);
}
pub fn stats_snapshot(&self) -> CacheStatsSnapshot {
self.stats.snapshot()
}
pub fn set(
&mut self,
relative_path: &str,
content_hash: u64,
mtime_secs: u64,
file_size: u64,
semantics: &SourceSemantics,
) {
if !self.enabled {
return;
}
let path_hash = xxh3_64(relative_path.as_bytes());
let safe_path = relative_path.replace(['/', '\\', ':'], "_");
let truncated_path: String = safe_path.chars().take(40).collect();
let filename = format!(
"{:016x}_{:016x}_{:016x}_{:016x}_{}.msgpack",
content_hash, path_hash, mtime_secs, file_size, truncated_path
);
let cache_path = self.cache_dir.join(&filename);
if let Ok(file) = fs::File::create(&cache_path) {
let mut writer = BufWriter::new(file);
if rmp_serde::encode::write(&mut writer, semantics).is_ok() {
self.index.insert(
path_hash,
CacheEntry {
cache_path,
content_hash,
mtime_secs,
file_size,
},
);
self.save_index();
}
}
}
pub fn clear(&self) -> Result<()> {
if !self.enabled {
return Ok(());
}
for entry in fs::read_dir(&self.cache_dir)? {
let entry = entry?;
if entry.path().extension().map_or(false, |e| e == "msgpack") {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use unfault_core::parse::ast::FileId;
use unfault_core::parse::python;
use unfault_core::semantics::python::model::PyFileSemantics;
use unfault_core::types::context::{Language, SourceFile};
fn create_test_semantics() -> SourceSemantics {
let source_file = SourceFile {
path: "test.py".to_string(),
language: Language::Python,
content: "def hello():\n pass\n".to_string(),
};
let parsed = python::parse_python_file(FileId(1), &source_file).unwrap();
let sem = PyFileSemantics::from_parsed(&parsed);
SourceSemantics::Python(sem)
}
#[test]
fn test_hash_content() {
let hash1 = SemanticsCache::hash_content("hello world");
let hash2 = SemanticsCache::hash_content("hello world");
let hash3 = SemanticsCache::hash_content("hello world!");
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
#[test]
fn test_cache_miss_on_empty() {
let temp_dir = TempDir::new().unwrap();
let cache = SemanticsCache::open(temp_dir.path()).unwrap();
let result = cache.get("test.py", 12345);
assert!(result.is_none());
assert_eq!(cache.stats.misses.load(Ordering::Relaxed), 1);
}
#[test]
fn test_cache_hit() {
let temp_dir = TempDir::new().unwrap();
let mut cache = SemanticsCache::open(temp_dir.path()).unwrap();
let semantics = create_test_semantics();
let content_hash = 12345u64;
cache.set("test.py", content_hash, 1000, 512, &semantics);
let result = cache.get("test.py", content_hash);
assert!(result.is_some());
assert_eq!(cache.stats.hits.load(Ordering::Relaxed), 1);
}
#[test]
fn test_cache_miss_on_hash_change() {
let temp_dir = TempDir::new().unwrap();
let mut cache = SemanticsCache::open(temp_dir.path()).unwrap();
let semantics = create_test_semantics();
cache.set("test.py", 12345, 1000, 512, &semantics);
let result = cache.get("test.py", 99999);
assert!(result.is_none());
assert_eq!(cache.stats.misses.load(Ordering::Relaxed), 1);
}
#[test]
fn test_cache_persistence() {
let temp_dir = TempDir::new().unwrap();
let content_hash = 12345u64;
{
let mut cache = SemanticsCache::open(temp_dir.path()).unwrap();
let semantics = create_test_semantics();
cache.set("test.py", content_hash, 1000, 512, &semantics);
}
{
let cache = SemanticsCache::open(temp_dir.path()).unwrap();
let result = cache.get("test.py", content_hash);
assert!(result.is_some());
}
}
#[test]
fn test_hit_rate() {
let stats = CacheStatsSnapshot {
hits: 80,
misses: 20,
};
assert!((stats.hit_rate() - 80.0).abs() < 0.01);
}
#[test]
fn test_disabled_cache() {
let mut cache = SemanticsCache::disabled();
let semantics = create_test_semantics();
cache.set("test.py", 12345, 1000, 512, &semantics);
let result = cache.get("test.py", 12345);
assert!(result.is_none());
}
#[test]
fn check_metadata_matches() {
let temp_dir = TempDir::new().unwrap();
let mut cache = SemanticsCache::open(temp_dir.path()).unwrap();
let semantics = create_test_semantics();
cache.set("test.py", 99999, 1234567890, 1024, &semantics);
let result = cache.check_metadata("test.py", 1234567890, 1024);
assert!(result.is_some());
let result = cache.check_metadata("test.py", 1234567891, 1024);
assert!(result.is_none());
let result = cache.check_metadata("test.py", 1234567890, 1025);
assert!(result.is_none());
}
}