use crate::diagnostics::Diagnostic;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
const CACHE_VERSION: u32 = 1;
const CACHE_FILENAME: &str = ".rust-doctor-cache.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FileEntry {
hash: String,
diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanCache {
version: u32,
config_hash: String,
files: HashMap<PathBuf, FileEntry>,
}
impl ScanCache {
pub fn new(config_hash: String) -> Self {
Self {
version: CACHE_VERSION,
config_hash,
files: HashMap::new(),
}
}
pub fn load(project_root: &Path, config_hash: &str) -> Option<Self> {
let cache_path = project_root.join(CACHE_FILENAME);
let content = std::fs::read_to_string(&cache_path).ok()?;
let cache: Self = serde_json::from_str(&content).ok()?;
if cache.version != CACHE_VERSION {
return None;
}
if cache.config_hash != config_hash {
return None;
}
Some(cache)
}
pub fn save(&self, project_root: &Path) {
let cache_path = project_root.join(CACHE_FILENAME);
if let Ok(json) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(&cache_path, json);
}
}
pub fn is_fresh(&self, path: &Path, content: &str) -> bool {
self.files
.get(path)
.is_some_and(|entry| entry.hash == hash_content(content))
}
pub fn get_cached_diagnostics(&self, path: &Path) -> Option<&[Diagnostic]> {
self.files
.get(path)
.map(|entry| entry.diagnostics.as_slice())
}
pub fn update(&mut self, path: &Path, content: &str, diagnostics: Vec<Diagnostic>) {
self.files.insert(
path.to_path_buf(),
FileEntry {
hash: hash_content(content),
diagnostics,
},
);
}
}
pub fn hash_content(content: &str) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
pub fn compute_config_hash(
ignore_rules: &[String],
ignore_files: &[String],
enable_rules: &[String],
active_rule_names: &[&str],
) -> String {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
ignore_rules.hash(&mut hasher);
ignore_files.hash(&mut hasher);
enable_rules.hash(&mut hasher);
active_rule_names.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagnostics::{Category, Severity};
use std::path::PathBuf;
fn sample_diagnostic(file: &str, rule: &str) -> Diagnostic {
Diagnostic {
file_path: PathBuf::from(file),
rule: rule.to_string(),
category: Category::ErrorHandling,
severity: Severity::Warning,
message: "test message".to_string(),
help: None,
line: Some(1),
column: None,
fix: None,
}
}
#[test]
fn test_load_save_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let mut cache = ScanCache::new(config_hash.clone());
let diag = sample_diagnostic("src/main.rs", "unwrap-in-production");
cache.update(Path::new("src/main.rs"), "fn main() {}", vec![diag]);
cache.save(dir.path());
let loaded = ScanCache::load(dir.path(), &config_hash);
assert!(loaded.is_some(), "cache should load successfully");
let loaded = loaded.unwrap();
assert_eq!(loaded.version, CACHE_VERSION);
assert_eq!(loaded.config_hash, config_hash);
assert!(loaded.files.contains_key(Path::new("src/main.rs")));
let entry = loaded.files.get(Path::new("src/main.rs")).unwrap();
assert_eq!(entry.diagnostics.len(), 1);
assert_eq!(entry.diagnostics[0].rule, "unwrap-in-production");
}
#[test]
fn test_fresh_file_returns_cached_diagnostics() {
let content = "fn main() { println!(\"hello\"); }";
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let mut cache = ScanCache::new(config_hash);
let diag = sample_diagnostic("src/main.rs", "test-rule");
cache.update(Path::new("src/main.rs"), content, vec![diag]);
assert!(cache.is_fresh(Path::new("src/main.rs"), content));
let cached = cache.get_cached_diagnostics(Path::new("src/main.rs"));
assert!(cached.is_some());
assert_eq!(cached.unwrap().len(), 1);
assert_eq!(cached.unwrap()[0].rule, "test-rule");
}
#[test]
fn test_modified_file_is_stale() {
let original = "fn main() {}";
let modified = "fn main() { todo!() }";
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let mut cache = ScanCache::new(config_hash);
cache.update(
Path::new("src/main.rs"),
original,
vec![sample_diagnostic("src/main.rs", "r1")],
);
assert!(cache.is_fresh(Path::new("src/main.rs"), original));
assert!(!cache.is_fresh(Path::new("src/main.rs"), modified));
}
#[test]
fn test_config_change_invalidates_cache() {
let dir = tempfile::tempdir().unwrap();
let hash_v1 = compute_config_hash(&["rule-a".to_string()], &[], &[], &[]);
let hash_v2 = compute_config_hash(&["rule-b".to_string()], &[], &[], &[]);
let mut cache = ScanCache::new(hash_v1.clone());
cache.update(
Path::new("src/main.rs"),
"fn main() {}",
vec![sample_diagnostic("src/main.rs", "r1")],
);
cache.save(dir.path());
assert!(ScanCache::load(dir.path(), &hash_v1).is_some());
assert!(ScanCache::load(dir.path(), &hash_v2).is_none());
}
#[test]
fn test_missing_cache_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let loaded = ScanCache::load(dir.path(), &config_hash);
assert!(loaded.is_none());
}
#[test]
fn test_corrupt_cache_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
let cache_path = dir.path().join(CACHE_FILENAME);
std::fs::write(&cache_path, "not valid json {{").unwrap();
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let loaded = ScanCache::load(dir.path(), &config_hash);
assert!(loaded.is_none());
}
#[test]
fn test_wrong_version_returns_none() {
let dir = tempfile::tempdir().unwrap();
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let bad = serde_json::json!({
"version": 999,
"config_hash": config_hash,
"files": {}
});
let cache_path = dir.path().join(CACHE_FILENAME);
std::fs::write(&cache_path, serde_json::to_string(&bad).unwrap()).unwrap();
let loaded = ScanCache::load(dir.path(), &config_hash);
assert!(loaded.is_none());
}
#[test]
fn test_hash_content_deterministic() {
let content = "fn main() { let x = 42; }";
let h1 = hash_content(content);
let h2 = hash_content(content);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 16); }
#[test]
fn test_hash_content_differs() {
let h1 = hash_content("fn main() {}");
let h2 = hash_content("fn main() { todo!() }");
assert_ne!(h1, h2);
}
#[test]
fn test_get_cached_diagnostics_unknown_path() {
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let cache = ScanCache::new(config_hash);
assert!(
cache
.get_cached_diagnostics(Path::new("nonexistent.rs"))
.is_none()
);
}
#[test]
fn test_update_overwrites_previous_entry() {
let config_hash = compute_config_hash(&[], &[], &[], &[]);
let mut cache = ScanCache::new(config_hash);
cache.update(
Path::new("src/lib.rs"),
"v1",
vec![sample_diagnostic("src/lib.rs", "rule-a")],
);
assert_eq!(
cache
.get_cached_diagnostics(Path::new("src/lib.rs"))
.unwrap()
.len(),
1
);
cache.update(
Path::new("src/lib.rs"),
"v2",
vec![
sample_diagnostic("src/lib.rs", "rule-b"),
sample_diagnostic("src/lib.rs", "rule-c"),
],
);
let diags = cache
.get_cached_diagnostics(Path::new("src/lib.rs"))
.unwrap();
assert_eq!(diags.len(), 2);
assert_eq!(diags[0].rule, "rule-b");
}
}