use super::hash::{file_hash, is_file_changed};
use super::types::{CacheEntry, CacheStats, CachedIssue};
use crate::utils::types::LintIssue;
use crate::{Language, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
const CACHE_VERSION: u32 = 1;
const DEFAULT_CACHE_MAX_AGE_DAYS: u32 = 30;
const CACHE_FILE: &str = "lint-cache.json";
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LintCache {
pub version: u32,
pub entries: HashMap<String, HashMap<PathBuf, CacheEntry>>,
#[serde(skip)]
stats: CacheStats,
}
impl LintCache {
pub fn new() -> Self {
Self {
version: CACHE_VERSION,
entries: HashMap::new(),
stats: CacheStats::new(),
}
}
pub fn load(project_root: &Path) -> Result<Self> {
let cache_path = Self::cache_path(project_root);
if !cache_path.exists() {
return Ok(Self::new());
}
let content = fs::read_to_string(&cache_path)?;
let cache: Self = match serde_json::from_str(&content) {
Ok(c) => c,
Err(_) => {
log::warn!("Cache corrupted, creating new cache");
return Ok(Self::new());
}
};
if cache.version != CACHE_VERSION {
log::info!("Cache version mismatch, creating new cache");
return Ok(Self::new());
}
Ok(cache)
}
pub fn save(&self, project_root: &Path) -> Result<()> {
let cache_dir = crate::utils::get_cache_dir();
if !cache_dir.exists() {
fs::create_dir_all(&cache_dir)?;
}
let cache_path = Self::cache_path(project_root);
let content = serde_json::to_string_pretty(self)?;
fs::write(&cache_path, content)?;
Ok(())
}
fn cache_path(_project_root: &Path) -> PathBuf {
crate::utils::get_cache_dir().join(CACHE_FILE)
}
pub fn clear(project_root: &Path) -> Result<()> {
let cache_path = Self::cache_path(project_root);
if cache_path.exists() {
fs::remove_file(&cache_path)?;
}
Ok(())
}
pub fn check_file(
&mut self,
checker_name: &str,
file_path: &Path,
project_root: &Path,
) -> Option<Vec<LintIssue>> {
let relative_path = file_path
.strip_prefix(project_root)
.unwrap_or(file_path)
.to_path_buf();
let checker_cache = match self.entries.get(checker_name) {
Some(cache) => cache,
None => {
self.stats.cache_misses += 1;
return None;
}
};
let entry = match checker_cache.get(&relative_path) {
Some(e) => e,
None => {
self.stats.cache_misses += 1;
return None;
}
};
match is_file_changed(file_path, entry.mtime, entry.content_hash) {
Ok(true) => {
self.stats.cache_misses += 1;
self.stats.invalidated += 1;
None
}
Ok(false) => {
self.stats.cache_hits += 1;
let language = Language::from_path(file_path);
Some(entry.to_lint_issues(file_path, language))
}
Err(_) => {
self.stats.cache_misses += 1;
None
}
}
}
pub fn update_file(
&mut self,
checker_name: &str,
file_path: &Path,
project_root: &Path,
issues: &[LintIssue],
) -> Result<()> {
let relative_path = file_path
.strip_prefix(project_root)
.unwrap_or(file_path)
.to_path_buf();
let content_hash = file_hash(file_path)?;
let mtime = fs::metadata(file_path)?.modified()?;
let cached_issues: Vec<CachedIssue> =
issues.iter().map(CachedIssue::from_lint_issue).collect();
let entry = CacheEntry::new(content_hash, mtime, cached_issues);
self.entries
.entry(checker_name.to_string())
.or_default()
.insert(relative_path, entry);
Ok(())
}
pub fn prune(&mut self, max_age_days: Option<u32>) {
let days = max_age_days.unwrap_or(DEFAULT_CACHE_MAX_AGE_DAYS);
if days == 0 {
return; }
let max_age = Duration::from_secs(days as u64 * 24 * 60 * 60);
let now = SystemTime::now();
for checker_entries in self.entries.values_mut() {
checker_entries.retain(|_, entry| {
now.duration_since(entry.cached_at)
.map(|age| age < max_age)
.unwrap_or(false)
});
}
self.entries.retain(|_, entries| !entries.is_empty());
}
pub fn stats(&self) -> &CacheStats {
&self.stats
}
pub fn reset_stats(&mut self) {
self.stats = CacheStats::new();
}
pub fn has_entries(&self, checker_name: &str) -> bool {
self.entries
.get(checker_name)
.map(|e| !e.is_empty())
.unwrap_or(false)
}
pub fn total_entries(&self) -> usize {
self.entries.values().map(|e| e.len()).sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::types::Severity;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_cache_new() {
let cache = LintCache::new();
assert_eq!(cache.version, CACHE_VERSION);
assert!(cache.entries.is_empty());
}
static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct HomeGuard {
_lock: std::sync::MutexGuard<'static, ()>,
original: Option<String>,
}
impl HomeGuard {
fn new(new_home: &Path) -> Self {
let lock = CACHE_TEST_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let original = std::env::var("HOME").ok();
std::env::set_var("HOME", new_home);
Self {
_lock: lock,
original,
}
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn test_cache_save_load() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let _guard = HomeGuard::new(project_root);
let mut cache = LintCache::new();
cache
.entries
.insert("test_checker".to_string(), HashMap::new());
cache.save(project_root).unwrap();
let loaded = LintCache::load(project_root).unwrap();
assert_eq!(loaded.version, CACHE_VERSION);
assert!(loaded.entries.contains_key("test_checker"));
}
#[test]
fn test_cache_clear() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let _guard = HomeGuard::new(project_root);
let cache = LintCache::new();
cache.save(project_root).unwrap();
let cache_path = crate::utils::get_cache_dir().join(CACHE_FILE);
assert!(cache_path.exists());
LintCache::clear(project_root).unwrap();
assert!(!cache_path.exists());
}
#[test]
fn test_cache_update_and_check() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let test_file = project_root.join("test.rs");
let mut file = fs::File::create(&test_file).unwrap();
writeln!(file, "fn main() {{}}").unwrap();
let mut cache = LintCache::new();
assert!(cache
.check_file("clippy", &test_file, project_root)
.is_none());
let issues = vec![LintIssue::new(
test_file.clone(),
1,
"test issue".to_string(),
Severity::Warning,
)];
cache
.update_file("clippy", &test_file, project_root, &issues)
.unwrap();
let cached = cache.check_file("clippy", &test_file, project_root);
assert!(cached.is_some());
assert_eq!(cached.unwrap().len(), 1);
}
#[test]
fn test_cache_stats() {
let temp_dir = TempDir::new().unwrap();
let project_root = temp_dir.path();
let test_file = project_root.join("test.rs");
let mut file = fs::File::create(&test_file).unwrap();
writeln!(file, "fn main() {{}}").unwrap();
let mut cache = LintCache::new();
cache.check_file("clippy", &test_file, project_root);
assert_eq!(cache.stats().cache_misses, 1);
cache
.update_file("clippy", &test_file, project_root, &[])
.unwrap();
cache.check_file("clippy", &test_file, project_root);
assert_eq!(cache.stats().cache_hits, 1);
}
#[test]
fn test_cache_prune() {
let mut cache = LintCache::new();
let mut checker_entries = HashMap::new();
let old_entry = CacheEntry {
content_hash: 12345,
mtime: SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60), issues: vec![],
cached_at: SystemTime::now() - Duration::from_secs(30 * 24 * 60 * 60),
};
checker_entries.insert(PathBuf::from("old.rs"), old_entry);
cache.entries.insert("clippy".to_string(), checker_entries);
assert_eq!(cache.total_entries(), 1);
cache.prune(Some(7));
assert_eq!(cache.total_entries(), 0);
}
}