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 = 4;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct CacheMeta {
version: u32,
created_at: 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 {
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?;
if entry.path().extension().map_or(false, |e| e == "msgpack") {
let _ = fs::remove_file(entry.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 mut index = HashMap::new();
for entry in fs::read_dir(&cache_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |e| e == "msgpack") {
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);
index.insert(
path_hash,
CacheEntry {
cache_path: path.clone(),
content_hash,
mtime_secs,
file_size,
},
);
}
}
}
}
Ok(Self {
cache_dir,
index,
stats: CacheStats::default(),
enabled: true,
})
}
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,
},
);
}
}
}
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());
}
}