use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct CacheConfig {
pub enabled: bool,
pub cache_dir: Option<String>,
pub ttl_hours: u32,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
cache_dir: None,
ttl_hours: 168, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedFileAnalysis {
pub file_hash: String,
pub language: String,
pub config_hash: String,
pub timestamp: u64,
pub nodes: Vec<CachedNode>,
pub edges: Vec<CachedEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CachedNode {
pub id: String,
pub name: String,
pub full_name: String,
pub kind: String,
pub file: String,
pub language: String,
pub visibility: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CachedEdge {
pub from_id: String,
pub to_id: String,
pub confidence: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedDeadCodeFindings {
pub file_hash: String,
pub config_hash: String,
pub timestamp: u64,
pub dead_nodes: Vec<String>,
pub confidences: HashMap<String, f64>,
}
pub struct CacheStore {
cache_dir: PathBuf,
enabled: bool,
}
impl CacheStore {
pub fn new(config: &CacheConfig) -> Result<Self, crate::core::Error> {
let cache_dir = if let Some(dir) = &config.cache_dir {
PathBuf::from(dir)
} else {
let home = dirs::home_dir().ok_or_else(|| {
crate::core::Error::config("Cannot find home directory".to_string())
})?;
home.join(".fossil-cache")
};
if config.enabled {
fs::create_dir_all(&cache_dir)
.map_err(|e| crate::core::Error::config(format!("Cannot create cache dir: {e}")))?;
}
Ok(Self {
cache_dir,
enabled: config.enabled,
})
}
pub fn cache_key(file_hash: &str, language: &str, config_hash: &str) -> String {
format!("{}_{}_{}", file_hash, language, config_hash)
}
pub fn store_file_analysis(
&self,
analysis: &CachedFileAnalysis,
) -> Result<(), crate::core::Error> {
if !self.enabled {
return Ok(());
}
let key = Self::cache_key(
&analysis.file_hash,
&analysis.language,
&analysis.config_hash,
);
let cache_file = self.cache_dir.join(format!("{}.json", key));
let json = serde_json::to_string(analysis)
.map_err(|e| crate::core::Error::config(format!("Cannot serialize cache: {e}")))?;
fs::write(cache_file, json)
.map_err(|e| crate::core::Error::config(format!("Cannot write cache file: {e}")))?;
Ok(())
}
pub fn get_file_analysis(
&self,
file_hash: &str,
language: &str,
config_hash: &str,
) -> Result<Option<CachedFileAnalysis>, crate::core::Error> {
if !self.enabled {
return Ok(None);
}
let key = Self::cache_key(file_hash, language, config_hash);
let cache_file = self.cache_dir.join(format!("{}.json", key));
if !cache_file.exists() {
return Ok(None);
}
let json = fs::read_to_string(&cache_file)
.map_err(|e| crate::core::Error::config(format!("Cannot read cache file: {e}")))?;
let analysis: CachedFileAnalysis = serde_json::from_str(&json)
.map_err(|e| crate::core::Error::config(format!("Cannot deserialize cache: {e}")))?;
Ok(Some(analysis))
}
pub fn store_dead_code_findings(
&self,
findings: &CachedDeadCodeFindings,
) -> Result<(), crate::core::Error> {
if !self.enabled {
return Ok(());
}
let key = format!("deadcode_{}_{}", findings.file_hash, findings.config_hash);
let cache_file = self.cache_dir.join(format!("{}.json", key));
let json = serde_json::to_string(findings)
.map_err(|e| crate::core::Error::config(format!("Cannot serialize cache: {e}")))?;
fs::write(cache_file, json)
.map_err(|e| crate::core::Error::config(format!("Cannot write cache file: {e}")))?;
Ok(())
}
pub fn get_dead_code_findings(
&self,
file_hash: &str,
config_hash: &str,
) -> Result<Option<CachedDeadCodeFindings>, crate::core::Error> {
if !self.enabled {
return Ok(None);
}
let key = format!("deadcode_{}_{}", file_hash, config_hash);
let cache_file = self.cache_dir.join(format!("{}.json", key));
if !cache_file.exists() {
return Ok(None);
}
let json = fs::read_to_string(&cache_file)
.map_err(|e| crate::core::Error::config(format!("Cannot read cache file: {e}")))?;
let findings: CachedDeadCodeFindings = serde_json::from_str(&json)
.map_err(|e| crate::core::Error::config(format!("Cannot deserialize cache: {e}")))?;
Ok(Some(findings))
}
pub fn clear(&self) -> Result<(), crate::core::Error> {
if self.enabled && self.cache_dir.exists() {
fs::remove_dir_all(&self.cache_dir)
.map_err(|e| crate::core::Error::config(format!("Cannot clear cache: {e}")))?;
fs::create_dir_all(&self.cache_dir).map_err(|e| {
crate::core::Error::config(format!("Cannot recreate cache dir: {e}"))
})?;
}
Ok(())
}
pub fn cleanup(&self, max_age_hours: u32) -> Result<usize, crate::core::Error> {
if !self.enabled || !self.cache_dir.exists() {
return Ok(0);
}
let max_age = std::time::Duration::from_secs(u64::from(max_age_hours) * 3600);
let now = std::time::SystemTime::now();
let mut removed = 0;
let entries = fs::read_dir(&self.cache_dir)
.map_err(|e| crate::core::Error::config(format!("Cannot read cache dir: {e}")))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
if let Ok(metadata) = fs::metadata(&path) {
let is_expired = metadata
.modified()
.ok()
.and_then(|mtime| now.duration_since(mtime).ok())
.is_some_and(|age| age > max_age);
if is_expired {
let _ = fs::remove_file(&path);
removed += 1;
}
}
}
Ok(removed)
}
pub fn get_stats(&self) -> Result<CacheStats, crate::core::Error> {
let mut stats = CacheStats::default();
if !self.cache_dir.exists() {
return Ok(stats);
}
let entries = fs::read_dir(&self.cache_dir)
.map_err(|e| crate::core::Error::config(format!("Cannot read cache dir: {e}")))?;
for entry in entries {
let entry = entry
.map_err(|e| crate::core::Error::config(format!("Cannot read cache entry: {e}")))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
stats.total_files += 1;
if let Ok(metadata) = fs::metadata(&path) {
stats.total_size_bytes += metadata.len() as usize;
}
}
}
Ok(stats)
}
}
#[derive(Debug, Default, Clone)]
pub struct CacheStats {
pub total_files: usize,
pub total_size_bytes: usize,
pub hits: usize,
pub misses: usize,
}
impl CacheStats {
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
}
}
pub fn total_size_kb(&self) -> f64 {
self.total_size_bytes as f64 / 1024.0
}
pub fn total_size_mb(&self) -> f64 {
self.total_size_bytes as f64 / (1024.0 * 1024.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cache_key_format() {
let key = CacheStore::cache_key("abc123", "rust", "def456");
assert_eq!(key, "abc123_rust_def456");
}
#[test]
fn test_cache_stats_hit_rate() {
let stats = CacheStats {
hits: 90,
misses: 10,
..Default::default()
};
assert_eq!(stats.hit_rate(), 90.0);
}
#[test]
fn test_cache_stats_empty() {
let stats = CacheStats::default();
assert_eq!(stats.hit_rate(), 0.0);
}
}