use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::error::RepoLensError;
use crate::rules::results::Finding;
const DEFAULT_CACHE_DIR: &str = ".repolens/cache";
const DEFAULT_MAX_AGE_HOURS: u64 = 24;
const CACHE_FILE_NAME: &str = "audit_cache.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheEntry {
pub file_path: String,
pub content_hash: String,
pub findings: Vec<Finding>,
pub timestamp: u64,
}
impl CacheEntry {
#[allow(dead_code)]
pub fn new(file_path: String, content_hash: String, findings: Vec<Finding>) -> Self {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
Self {
file_path,
content_hash,
findings,
timestamp,
}
}
pub fn is_expired(&self, max_age_hours: u64) -> bool {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs();
let max_age_secs = max_age_hours * 3600;
now.saturating_sub(self.timestamp) > max_age_secs
}
#[allow(dead_code)]
pub fn matches_hash(&self, current_hash: &str) -> bool {
self.content_hash == current_hash
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_max_age_hours")]
pub max_age_hours: u64,
#[serde(default = "default_directory")]
pub directory: String,
}
fn default_enabled() -> bool {
true
}
fn default_max_age_hours() -> u64 {
DEFAULT_MAX_AGE_HOURS
}
fn default_directory() -> String {
DEFAULT_CACHE_DIR.to_string()
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
enabled: true,
max_age_hours: DEFAULT_MAX_AGE_HOURS,
directory: DEFAULT_CACHE_DIR.to_string(),
}
}
}
#[derive(Debug)]
pub struct AuditCache {
entries: HashMap<PathBuf, CacheEntry>,
cache_dir: PathBuf,
#[allow(dead_code)]
config: CacheConfig,
dirty: bool,
}
impl AuditCache {
#[allow(dead_code)]
pub fn new(project_root: &Path, config: CacheConfig) -> Self {
let cache_dir = Self::resolve_cache_dir(project_root, &config.directory);
Self {
entries: HashMap::new(),
cache_dir,
config,
dirty: false,
}
}
fn resolve_cache_dir(project_root: &Path, directory: &str) -> PathBuf {
let path = Path::new(directory);
if path.is_absolute() {
path.to_path_buf()
} else if directory.starts_with("~") {
if let Some(home) = dirs::home_dir() {
home.join(directory.trim_start_matches("~/"))
} else {
project_root.join(directory)
}
} else {
project_root.join(directory)
}
}
pub fn load(project_root: &Path, config: CacheConfig) -> Self {
let cache_dir = Self::resolve_cache_dir(project_root, &config.directory);
let cache_file = cache_dir.join(CACHE_FILE_NAME);
let entries = if cache_file.exists() {
match fs::read_to_string(&cache_file) {
Ok(content) => match serde_json::from_str::<Vec<CacheEntry>>(&content) {
Ok(entries) => {
let mut map = HashMap::new();
for entry in entries {
if !entry.is_expired(config.max_age_hours) {
map.insert(PathBuf::from(&entry.file_path), entry);
}
}
tracing::debug!(
"Loaded {} cache entries from {}",
map.len(),
cache_file.display()
);
map
}
Err(e) => {
tracing::warn!("Failed to parse cache file: {}", e);
HashMap::new()
}
},
Err(e) => {
tracing::debug!("Failed to read cache file: {}", e);
HashMap::new()
}
}
} else {
tracing::debug!("No cache file found at {}", cache_file.display());
HashMap::new()
};
Self {
entries,
cache_dir,
config,
dirty: false,
}
}
pub fn save(&self) -> Result<(), RepoLensError> {
if !self.dirty {
tracing::debug!("Cache not modified, skipping save");
return Ok(());
}
fs::create_dir_all(&self.cache_dir).map_err(|e| {
RepoLensError::Action(crate::error::ActionError::DirectoryCreate {
path: self.cache_dir.display().to_string(),
source: e,
})
})?;
let cache_file = self.cache_dir.join(CACHE_FILE_NAME);
let entries: Vec<&CacheEntry> = self.entries.values().collect();
let content = serde_json::to_string_pretty(&entries)?;
fs::write(&cache_file, content).map_err(|e| {
RepoLensError::Action(crate::error::ActionError::FileWrite {
path: cache_file.display().to_string(),
source: e,
})
})?;
tracing::debug!(
"Saved {} cache entries to {}",
entries.len(),
cache_file.display()
);
Ok(())
}
#[allow(dead_code)]
pub fn get(&self, file_path: &Path, current_hash: &str) -> Option<&Vec<Finding>> {
self.entries.get(file_path).and_then(|entry| {
if entry.matches_hash(current_hash) && !entry.is_expired(self.config.max_age_hours) {
tracing::trace!("Cache hit for {}", file_path.display());
Some(&entry.findings)
} else {
tracing::trace!("Cache miss for {} (hash or expiry)", file_path.display());
None
}
})
}
#[allow(dead_code)]
pub fn insert(&mut self, file_path: PathBuf, hash: String, findings: Vec<Finding>) {
let entry = CacheEntry::new(file_path.to_string_lossy().to_string(), hash, findings);
self.entries.insert(file_path, entry);
self.dirty = true;
}
#[allow(dead_code)]
pub fn invalidate(&mut self, file_path: &Path) {
if self.entries.remove(file_path).is_some() {
self.dirty = true;
tracing::debug!("Invalidated cache for {}", file_path.display());
}
}
#[allow(dead_code)]
pub fn clear(&mut self) {
if !self.entries.is_empty() {
self.entries.clear();
self.dirty = true;
tracing::info!("Cleared all cache entries");
}
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.entries.len()
}
#[allow(dead_code)]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn stats(&self) -> CacheStats {
CacheStats {
total_entries: self.entries.len(),
cache_dir: self.cache_dir.clone(),
}
}
#[allow(dead_code)]
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
}
#[derive(Debug)]
pub struct CacheStats {
pub total_entries: usize,
#[allow(dead_code)]
pub cache_dir: PathBuf,
}
#[allow(dead_code)]
pub fn calculate_file_hash(path: &Path) -> io::Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let bytes_read = file.read(&mut buffer)?;
if bytes_read == 0 {
break;
}
hasher.update(&buffer[..bytes_read]);
}
Ok(format!("{:x}", hasher.finalize()))
}
#[allow(dead_code)]
pub fn calculate_content_hash(content: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(content);
format!("{:x}", hasher.finalize())
}
pub fn delete_cache_directory(project_root: &Path, config: &CacheConfig) -> io::Result<()> {
let cache_dir = AuditCache::resolve_cache_dir(project_root, &config.directory);
if cache_dir.exists() {
fs::remove_dir_all(&cache_dir)?;
tracing::info!("Deleted cache directory: {}", cache_dir.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rules::results::Severity;
use std::fs;
use tempfile::TempDir;
fn create_test_finding(rule_id: &str, location: Option<&str>) -> Finding {
let mut finding = Finding::new(rule_id, "test", Severity::Warning, "Test finding");
if let Some(loc) = location {
finding = finding.with_location(loc);
}
finding
}
#[test]
fn test_cache_entry_creation() {
let entry = CacheEntry::new(
"test.rs".to_string(),
"abc123".to_string(),
vec![create_test_finding("TEST001", Some("test.rs:1"))],
);
assert_eq!(entry.file_path, "test.rs");
assert_eq!(entry.content_hash, "abc123");
assert_eq!(entry.findings.len(), 1);
assert!(entry.timestamp > 0);
}
#[test]
fn test_cache_entry_expiry() {
let mut entry = CacheEntry::new("test.rs".to_string(), "abc123".to_string(), vec![]);
assert!(!entry.is_expired(24));
entry.timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
- (25 * 3600);
assert!(entry.is_expired(24));
assert!(!entry.is_expired(48));
}
#[test]
fn test_cache_entry_hash_matching() {
let entry = CacheEntry::new("test.rs".to_string(), "abc123".to_string(), vec![]);
assert!(entry.matches_hash("abc123"));
assert!(!entry.matches_hash("def456"));
}
#[test]
fn test_audit_cache_new() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let cache = AuditCache::new(temp_dir.path(), config);
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
assert!(cache.is_enabled());
}
#[test]
fn test_audit_cache_insert_and_get() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
let findings = vec![create_test_finding("TEST001", Some("test.rs:1"))];
cache.insert(
PathBuf::from("test.rs"),
"abc123".to_string(),
findings.clone(),
);
let result = cache.get(Path::new("test.rs"), "abc123");
assert!(result.is_some());
assert_eq!(result.unwrap().len(), 1);
let result = cache.get(Path::new("test.rs"), "def456");
assert!(result.is_none());
let result = cache.get(Path::new("other.rs"), "abc123");
assert!(result.is_none());
}
#[test]
fn test_audit_cache_invalidate() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.insert(PathBuf::from("test.rs"), "abc123".to_string(), vec![]);
assert_eq!(cache.len(), 1);
cache.invalidate(Path::new("test.rs"));
assert!(cache.is_empty());
}
#[test]
fn test_audit_cache_clear() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.insert(PathBuf::from("test1.rs"), "abc123".to_string(), vec![]);
cache.insert(PathBuf::from("test2.rs"), "def456".to_string(), vec![]);
assert_eq!(cache.len(), 2);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_audit_cache_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config.clone());
let findings = vec![create_test_finding("TEST001", Some("test.rs:1"))];
cache.insert(
PathBuf::from("test.rs"),
"abc123".to_string(),
findings.clone(),
);
cache.save().unwrap();
let loaded_cache = AuditCache::load(temp_dir.path(), config);
assert_eq!(loaded_cache.len(), 1);
let result = loaded_cache.get(Path::new("test.rs"), "abc123");
assert!(result.is_some());
assert_eq!(result.unwrap().len(), 1);
}
#[test]
fn test_calculate_file_hash() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "Hello, World!").unwrap();
let hash = calculate_file_hash(&file_path).unwrap();
assert_eq!(hash.len(), 64);
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_calculate_content_hash() {
let hash = calculate_content_hash(b"Hello, World!");
let hash2 = calculate_content_hash(b"Hello, World!");
assert_eq!(hash, hash2);
let hash3 = calculate_content_hash(b"Different content");
assert_ne!(hash, hash3);
}
#[test]
fn test_delete_cache_directory() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config.clone());
cache.insert(PathBuf::from("test.rs"), "abc123".to_string(), vec![]);
cache.save().unwrap();
let cache_dir = temp_dir.path().join(".repolens/cache");
assert!(cache_dir.exists());
delete_cache_directory(temp_dir.path(), &config).unwrap();
assert!(!cache_dir.exists());
}
#[test]
fn test_cache_config_defaults() {
let config = CacheConfig::default();
assert!(config.enabled);
assert_eq!(config.max_age_hours, 24);
assert_eq!(config.directory, ".repolens/cache");
}
#[test]
fn test_cache_stats() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.insert(PathBuf::from("test1.rs"), "abc123".to_string(), vec![]);
cache.insert(PathBuf::from("test2.rs"), "def456".to_string(), vec![]);
let stats = cache.stats();
assert_eq!(stats.total_entries, 2);
}
#[test]
fn test_cache_save_not_dirty() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let cache = AuditCache::new(temp_dir.path(), config);
let result = cache.save();
assert!(result.is_ok());
let cache_file = temp_dir.path().join(".repolens/cache/audit_cache.json");
assert!(!cache_file.exists());
}
#[test]
fn test_cache_load_invalid_json() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join(".repolens/cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("audit_cache.json"), "invalid json content").unwrap();
let config = CacheConfig::default();
let cache = AuditCache::load(temp_dir.path(), config);
assert!(cache.is_empty());
}
#[test]
fn test_cache_load_expired_entries_filtered() {
let temp_dir = TempDir::new().unwrap();
let cache_dir = temp_dir.path().join(".repolens/cache");
fs::create_dir_all(&cache_dir).unwrap();
let old_entry = CacheEntry {
file_path: "old.rs".to_string(),
content_hash: "abc".to_string(),
findings: vec![],
timestamp: 1000, };
let content = serde_json::to_string_pretty(&vec![old_entry]).unwrap();
fs::write(cache_dir.join("audit_cache.json"), content).unwrap();
let config = CacheConfig {
max_age_hours: 1, ..Default::default()
};
let cache = AuditCache::load(temp_dir.path(), config);
assert!(cache.is_empty());
}
#[test]
fn test_cache_load_nonexistent_directory() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
directory: "nonexistent/path".to_string(),
..Default::default()
};
let cache = AuditCache::load(temp_dir.path(), config);
assert!(cache.is_empty());
}
#[test]
fn test_resolve_cache_dir_absolute() {
let config_dir = "/tmp/test-cache";
let resolved = AuditCache::resolve_cache_dir(Path::new("/project"), config_dir);
assert_eq!(resolved, PathBuf::from("/tmp/test-cache"));
}
#[test]
fn test_resolve_cache_dir_home() {
let resolved = AuditCache::resolve_cache_dir(Path::new("/project"), "~/.cache/repolens");
assert!(!resolved.starts_with("~"));
}
#[test]
fn test_cache_is_enabled_false() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig {
enabled: false,
..Default::default()
};
let cache = AuditCache::new(temp_dir.path(), config);
assert!(!cache.is_enabled());
}
#[test]
fn test_cache_clear_empty() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn test_cache_invalidate_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.invalidate(Path::new("nonexistent.rs"));
assert!(cache.is_empty());
}
#[test]
fn test_delete_cache_directory_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let result = delete_cache_directory(temp_dir.path(), &config);
assert!(result.is_ok());
}
#[test]
fn test_cache_get_wrong_hash() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.insert(PathBuf::from("test.rs"), "abc123".to_string(), vec![]);
assert!(cache.get(Path::new("test.rs"), "different_hash").is_none());
}
#[test]
fn test_cache_save_dirty() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config);
cache.insert(PathBuf::from("test.rs"), "abc123".to_string(), vec![]);
let result = cache.save();
assert!(result.is_ok());
let cache_file = temp_dir.path().join(".repolens/cache/audit_cache.json");
assert!(cache_file.exists());
}
#[test]
fn test_cache_save_and_load_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let mut cache = AuditCache::new(temp_dir.path(), config.clone());
let finding = create_test_finding("TEST001", Some("test.rs:1"));
cache.insert(
PathBuf::from("test.rs"),
"abc123".to_string(),
vec![finding],
);
cache.save().unwrap();
let loaded = AuditCache::load(temp_dir.path(), config);
assert!(!loaded.is_empty());
assert!(loaded.get(Path::new("test.rs"), "abc123").is_some());
}
#[test]
fn test_cache_delete_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let config = CacheConfig::default();
let cache_dir = temp_dir.path().join(".repolens/cache");
fs::create_dir_all(&cache_dir).unwrap();
fs::write(cache_dir.join("audit_cache.json"), "[]").unwrap();
let result = delete_cache_directory(temp_dir.path(), &config);
assert!(result.is_ok());
assert!(!cache_dir.exists());
}
#[test]
fn test_cache_entry_matches_hash() {
let entry = CacheEntry::new("test.rs".to_string(), "abc123".to_string(), vec![]);
assert!(entry.matches_hash("abc123"));
assert!(!entry.matches_hash("other"));
}
}