use rumdl_lib::rule::LintWarning;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Default, Clone)]
pub struct CacheStats {
pub hits: usize,
pub misses: usize,
pub writes: usize,
}
impl CacheStats {
#[cfg(test)]
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, Serialize, Deserialize)]
struct CacheEntry {
file_hash: String,
config_hash: String,
rules_hash: String,
version: String,
warnings: Vec<LintWarning>,
timestamp: i64,
}
pub struct LintCache {
cache_dir: PathBuf,
enabled: bool,
stats: CacheStats,
}
impl LintCache {
pub fn new(cache_dir: PathBuf, enabled: bool) -> Self {
Self {
cache_dir,
enabled,
stats: CacheStats::default(),
}
}
fn hash_content(content: &str) -> String {
blake3::hash(content.as_bytes()).to_hex().to_string()
}
pub fn hash_config(config: &rumdl_lib::config::Config) -> String {
let config_json = serde_json::to_string(config).unwrap_or_default();
blake3::hash(config_json.as_bytes()).to_hex().to_string()
}
pub fn hash_rules(rules: &[Box<dyn rumdl_lib::rule::Rule>]) -> String {
let mut rule_names: Vec<&str> = rules.iter().map(|r| r.name()).collect();
rule_names.sort_unstable();
let rules_str = rule_names.join(",");
blake3::hash(rules_str.as_bytes()).to_hex().to_string()
}
fn cache_file_path(&self, file_hash: &str, rules_hash: &str) -> PathBuf {
let short_rules_hash = &rules_hash[..16];
self.cache_dir
.join(VERSION)
.join(format!("{file_hash}_{short_rules_hash}.json"))
}
pub fn get(&mut self, content: &str, config_hash: &str, rules_hash: &str) -> Option<Vec<LintWarning>> {
if !self.enabled {
return None;
}
let file_hash = Self::hash_content(content);
let cache_path = self.cache_file_path(&file_hash, rules_hash);
let cache_data = match fs::read_to_string(&cache_path) {
Ok(data) => data,
Err(_) => {
self.stats.misses += 1;
return None;
}
};
let entry: CacheEntry = match serde_json::from_str(&cache_data) {
Ok(entry) => entry,
Err(_) => {
self.stats.misses += 1;
return None;
}
};
if entry.file_hash != file_hash
|| entry.config_hash != config_hash
|| entry.rules_hash != rules_hash
|| entry.version != VERSION
{
self.stats.misses += 1;
return None;
}
self.stats.hits += 1;
Some(entry.warnings)
}
pub fn set(&mut self, content: &str, config_hash: &str, rules_hash: &str, warnings: Vec<LintWarning>) {
if !self.enabled {
return;
}
let file_hash = Self::hash_content(content);
let cache_path = self.cache_file_path(&file_hash, rules_hash);
if let Some(parent) = cache_path.parent() {
let _ = fs::create_dir_all(parent);
}
let entry = CacheEntry {
file_hash,
config_hash: config_hash.to_string(),
rules_hash: rules_hash.to_string(),
version: VERSION.to_string(),
warnings,
timestamp: chrono::Utc::now().timestamp(),
};
if let Ok(json) = serde_json::to_string_pretty(&entry) {
match fs::write(&cache_path, &json) {
Ok(()) => self.stats.writes += 1,
Err(e) => log::debug!("Cache write failed for {}: {}", cache_path.display(), e),
}
}
}
pub fn clear(&self) -> std::io::Result<()> {
if self.cache_dir.exists() {
fs::remove_dir_all(&self.cache_dir)?;
}
Ok(())
}
pub fn init(&self) -> std::io::Result<()> {
if !self.enabled {
return Ok(());
}
let version_dir = self.cache_dir.join(VERSION);
fs::create_dir_all(&version_dir)?;
self.prune_old_versions()?;
let gitignore_path = self.cache_dir.join(".gitignore");
if !gitignore_path.exists() {
fs::write(gitignore_path, "# Automatically created by rumdl.\n*\n")?;
}
let cachedir_tag = self.cache_dir.join("CACHEDIR.TAG");
if !cachedir_tag.exists() {
fs::write(
cachedir_tag,
"Signature: 8a477f597d28d172789f06886806bc55\n# This file is a cache directory tag created by rumdl.\n",
)?;
}
Ok(())
}
fn prune_old_versions(&self) -> std::io::Result<()> {
if !self.cache_dir.exists() {
return Ok(());
}
let entries = fs::read_dir(&self.cache_dir)?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
if let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) {
if dir_name == VERSION {
continue;
}
if dir_name.chars().next().is_some_and(|c| c.is_ascii_digit()) {
log::info!("Pruning old cache version: {dir_name}");
if let Err(e) = fs::remove_dir_all(&path) {
log::warn!("Failed to prune old cache {dir_name}: {e}");
}
}
}
}
Ok(())
}
#[cfg(test)]
pub fn stats(&self) -> &CacheStats {
&self.stats
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_cache_disabled() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), false);
let content = "# Test";
let config_hash = "abc123";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(cache.get(content, config_hash, rules_hash).is_none());
cache.set(content, config_hash, rules_hash, vec![]);
assert_eq!(cache.stats().writes, 0);
}
#[test]
fn test_cache_miss() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
let content = "# Test";
let config_hash = "abc123";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
assert!(cache.get(content, config_hash, rules_hash).is_none());
assert_eq!(cache.stats().misses, 1);
assert_eq!(cache.stats().hits, 0);
}
#[test]
fn test_cache_hit() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
cache.init().unwrap();
let content = "# Test";
let config_hash = "abc123";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
let warnings = vec![];
cache.set(content, config_hash, rules_hash, warnings.clone());
let cached = cache.get(content, config_hash, rules_hash);
assert!(cached.is_some());
assert_eq!(cached.unwrap(), warnings);
assert_eq!(cache.stats().hits, 1);
}
#[test]
fn test_cache_invalidation_on_content_change() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
cache.init().unwrap();
let content1 = "# Test 1";
let content2 = "# Test 2";
let config_hash = "abc123";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
cache.set(content1, config_hash, rules_hash, vec![]);
assert!(cache.get(content2, config_hash, rules_hash).is_none());
}
#[test]
fn test_cache_invalidation_on_config_change() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
cache.init().unwrap();
let content = "# Test";
let config_hash1 = "abc123";
let config_hash2 = "def456";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
cache.set(content, config_hash1, rules_hash, vec![]);
assert!(cache.get(content, config_hash2, rules_hash).is_none());
}
#[test]
fn test_hash_content() {
let content1 = "# Test";
let content2 = "# Test";
let content3 = "# Different";
let hash1 = LintCache::hash_content(content1);
let hash2 = LintCache::hash_content(content2);
let hash3 = LintCache::hash_content(content3);
assert_eq!(hash1, hash2);
assert_ne!(hash1, hash3);
}
#[test]
fn test_cache_stats() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
cache.init().unwrap();
let content = "# Test";
let config_hash = "abc123";
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
cache.get(content, config_hash, rules_hash);
assert_eq!(cache.stats().misses, 1);
assert_eq!(cache.stats().hits, 0);
cache.set(content, config_hash, rules_hash, vec![]);
assert_eq!(cache.stats().writes, 1);
cache.get(content, config_hash, rules_hash);
assert_eq!(cache.stats().hits, 1);
assert_eq!(cache.stats().hit_rate(), 50.0); }
#[test]
fn test_cache_clear() {
let temp_dir = TempDir::new().unwrap();
let mut cache = LintCache::new(temp_dir.path().to_path_buf(), true);
cache.init().unwrap();
let rules_hash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
cache.set("# Test", "abc", rules_hash, vec![]);
cache.clear().unwrap();
assert!(!cache.cache_dir.exists());
}
#[test]
fn test_prune_old_versions() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().to_path_buf();
fs::create_dir_all(cache_dir.join("0.0.1")).unwrap();
fs::create_dir_all(cache_dir.join("0.0.50")).unwrap();
fs::create_dir_all(cache_dir.join("0.0.100")).unwrap();
fs::write(cache_dir.join("0.0.1").join("test.json"), "{}").unwrap();
fs::write(cache_dir.join("0.0.50").join("test.json"), "{}").unwrap();
fs::create_dir_all(cache_dir.join("some_other_dir")).unwrap();
let cache = LintCache::new(cache_dir.clone(), true);
cache.init().unwrap();
assert!(cache_dir.join(VERSION).exists());
assert!(!cache_dir.join("0.0.1").exists());
assert!(!cache_dir.join("0.0.50").exists());
assert!(!cache_dir.join("0.0.100").exists());
assert!(cache_dir.join("some_other_dir").exists());
}
}