use crate::models::{Finding, Grade, Severity};
use crate::parsers::ParseResult;
use anyhow::{Context, Result};
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
const CACHE_VERSION: u32 = 5;
const HASH_BUFFER_SIZE: usize = 65536;
const MAX_CACHE_FILES: usize = 100_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedFile {
hash: String,
findings: Vec<Finding>,
timestamp: u64,
#[serde(default)]
value_dependencies: Vec<String>,
#[serde(default)]
value_hashes: HashMap<String, u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CachedScoreResult {
pub score: f64,
pub grade: Grade,
pub total_files: usize,
pub total_functions: usize,
pub total_classes: usize,
#[serde(default)]
pub structure_score: Option<f64>,
#[serde(default)]
pub quality_score: Option<f64>,
#[serde(default)]
pub architecture_score: Option<f64>,
#[serde(default)]
pub total_loc: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct GraphCache {
hash: Option<String>,
detectors: HashMap<String, Vec<Finding>>,
#[serde(default)]
score: Option<CachedScoreResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedParseResult {
hash: String,
result: crate::parsers::ParseResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CacheData {
version: u32,
#[serde(default)]
binary_version: String,
files: HashMap<String, CachedFile>,
graph: GraphCache,
#[serde(default)]
parse_cache: HashMap<String, CachedParseResult>,
#[serde(default)]
fingerprint: Option<u64>,
}
impl Default for CacheData {
fn default() -> Self {
Self {
version: CACHE_VERSION,
binary_version: env!("CARGO_PKG_VERSION").to_string(),
files: HashMap::new(),
graph: GraphCache::default(),
parse_cache: HashMap::new(),
fingerprint: None,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CacheStats {
pub cached_files: usize,
pub total_findings: usize,
pub graph_hash: Option<String>,
pub graph_detectors: usize,
pub graph_findings: usize,
pub cache_version: u32,
}
pub fn binary_file_hash() -> Option<u64> {
let exe = std::env::current_exe().ok()?;
let bytes = fs::read(&exe).ok()?;
Some(xxhash_rust::xxh3::xxh3_64(&bytes))
}
fn sort_json_keys(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let sorted: serde_json::Map<String, serde_json::Value> = map
.into_iter()
.map(|(k, v)| (k, sort_json_keys(v)))
.collect::<std::collections::BTreeMap<_, _>>()
.into_iter()
.collect();
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_json_keys).collect())
}
other => other,
}
}
pub fn compute_fingerprint(
binary_hash: u64,
config: &crate::config::ProjectConfig,
all_detectors: bool,
) -> u64 {
let mut buf = Vec::with_capacity(256);
buf.extend_from_slice(&binary_hash.to_le_bytes());
match serde_json::to_value(config).and_then(|v| serde_json::to_string(&sort_json_keys(v))) {
Ok(json) => buf.extend_from_slice(json.as_bytes()),
Err(_) => buf.extend_from_slice(b"__config_serialize_error__"),
}
buf.push(all_detectors as u8);
buf.extend_from_slice(&CACHE_VERSION.to_le_bytes());
xxhash_rust::xxh3::xxh3_64(&buf)
}
pub struct IncrementalCache {
cache_dir: PathBuf,
cache_file: PathBuf,
cache: CacheData,
dirty: bool,
memoized_files_hash: Option<(usize, String)>, fingerprint_config: crate::config::ProjectConfig,
fingerprint_all_detectors: bool,
}
impl IncrementalCache {
pub fn new(
cache_dir: &Path,
config: &crate::config::ProjectConfig,
all_detectors: bool,
) -> Self {
let cache_dir = cache_dir.to_path_buf();
let cache_file = cache_dir.join("findings_cache.bin");
if let Err(e) = fs::create_dir_all(&cache_dir) {
warn!("Failed to create cache directory: {}", e);
}
let mut instance = Self {
cache_dir,
cache_file,
cache: CacheData::default(),
dirty: false,
memoized_files_hash: None,
fingerprint_config: config.clone(),
fingerprint_all_detectors: all_detectors,
};
if let Err(e) = instance.load_cache() {
debug!("Failed to load cache: {}", e);
}
instance
}
pub fn has_cache(&self) -> bool {
!self.cache.files.is_empty() || !self.cache.parse_cache.is_empty()
}
#[allow(dead_code)] pub fn cached_parse(&self, path: &Path) -> Option<crate::parsers::ParseResult> {
let key = path.to_string_lossy().to_string();
let hash = self.file_hash(path);
self.cache.parse_cache.get(&key).and_then(|cached| {
if cached.hash == hash {
Some(cached.result.clone())
} else {
None
}
})
}
pub fn cache_parse_result(&mut self, path: &Path, result: &crate::parsers::ParseResult) {
let key = path.to_string_lossy().to_string();
let hash = self.file_hash(path);
self.cache.parse_cache.insert(
key,
CachedParseResult {
hash,
result: result.clone(),
},
);
self.dirty = true;
}
pub fn file_hash(&self, path: &Path) -> String {
match fs::File::open(path) {
Ok(mut file) => {
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
let mut buffer = [0u8; HASH_BUFFER_SIZE];
loop {
match file.read(&mut buffer) {
Ok(0) => break,
Ok(n) => hasher.update(&buffer[..n]),
Err(_) => break,
}
}
format!("{:016x}", hasher.digest())
}
Err(_) => format!("error:{}", path.display()),
}
}
fn load_cache(&mut self) -> Result<()> {
if !self.cache_file.exists() {
debug!("No cache file found at {:?}", self.cache_file);
return Ok(());
}
let bytes = fs::read(&self.cache_file).context("Failed to read cache file")?;
let data: CacheData = bitcode::deserialize(&bytes).context("Failed to parse cache")?;
if data.version != CACHE_VERSION {
info!(
"Cache version mismatch (got {}, expected {}), rebuilding",
data.version, CACHE_VERSION
);
self.invalidate_all();
return Ok(());
}
let current_version = env!("CARGO_PKG_VERSION");
if !data.binary_version.is_empty() && data.binary_version != current_version {
info!(
"Binary version changed ({} → {}), invalidating cache",
data.binary_version, current_version
);
self.invalidate_all();
return Ok(());
}
let binary_hash = match binary_file_hash() {
Some(h) => h,
None => {
info!("Cannot hash binary, forcing cache invalidation");
self.invalidate_all();
return Ok(());
}
};
let current_fp = compute_fingerprint(
binary_hash,
&self.fingerprint_config,
self.fingerprint_all_detectors,
);
if data.fingerprint != Some(current_fp) {
info!("Cache fingerprint mismatch, rebuilding");
self.invalidate_all();
return Ok(());
}
self.cache = data;
debug!("Loaded cache with {} files", self.cache.files.len());
Ok(())
}
pub fn save_cache(&mut self) -> Result<()> {
if !self.dirty {
return Ok(());
}
if let Some(binary_hash) = binary_file_hash() {
self.cache.fingerprint = Some(compute_fingerprint(
binary_hash,
&self.fingerprint_config,
self.fingerprint_all_detectors,
));
}
let tmp_file = self.cache_file.with_extension("tmp");
let bytes = bitcode::serialize(&self.cache).context("Failed to serialize cache")?;
fs::write(&tmp_file, &bytes).context("Failed to write temp cache file")?;
fs::rename(&tmp_file, &self.cache_file).context("Failed to rename temp cache")?;
self.dirty = false;
debug!("Saved cache with {} files", self.cache.files.len());
Ok(())
}
#[allow(dead_code)] pub fn is_file_changed(&self, path: &Path) -> bool {
let path_key = self.path_key(path);
match self.cache.files.get(&path_key) {
None => true,
Some(cached) => {
let current_hash = self.file_hash(path);
cached.hash != current_hash
}
}
}
pub fn cached_findings(&self, path: &Path) -> Vec<Finding> {
let path_key = self.path_key(path);
match self.cache.files.get(&path_key) {
None => vec![],
Some(cached) => {
let current_hash = self.file_hash(path);
if cached.hash != current_hash {
return vec![];
}
cached.findings.clone()
}
}
}
pub fn cache_findings(&mut self, path: &Path, findings: &[Finding]) {
let path_key = self.path_key(path);
let file_hash = self.file_hash(path);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
self.cache.files.insert(
path_key,
CachedFile {
hash: file_hash,
findings: findings.to_vec(),
timestamp,
value_dependencies: Vec::new(),
value_hashes: HashMap::new(),
},
);
self.dirty = true;
self.evict_oldest_if_over_cap();
}
fn evict_oldest_if_over_cap(&mut self) {
if self.cache.files.len() <= MAX_CACHE_FILES {
return;
}
let mut stamps: Vec<(String, u64)> = self
.cache
.files
.iter()
.map(|(k, v)| (k.clone(), v.timestamp))
.collect();
stamps.sort_by_key(|(_, ts)| *ts);
let to_drop = stamps.len() - MAX_CACHE_FILES;
for (key, _) in stamps.into_iter().take(to_drop) {
self.cache.files.remove(&key);
self.cache.parse_cache.remove(&key);
}
warn!(
"Evicted {} cache entries to stay under MAX_CACHE_FILES={}",
to_drop, MAX_CACHE_FILES
);
}
pub fn changed_files(&self, all_files: &[PathBuf]) -> Vec<PathBuf> {
let mut changed = Vec::new();
for path in all_files {
let path_key = self.path_key(path);
match self.cache.files.get(&path_key) {
None => changed.push(path.clone()),
Some(cached) => {
let current_hash = self.file_hash(path);
if cached.hash != current_hash {
changed.push(path.clone());
}
}
}
}
debug!(
"Incremental analysis: {}/{} files changed",
changed.len(),
all_files.len()
);
changed
}
pub fn invalidate_file(&mut self, path: &Path) {
let path_key = self.path_key(path);
if self.cache.files.remove(&path_key).is_some() {
self.dirty = true;
}
}
pub fn invalidate_all(&mut self) {
self.cache = CacheData::default();
self.dirty = true;
}
pub fn prune_stale_entries(&mut self, current_files: &[PathBuf]) {
let current_keys: std::collections::HashSet<String> =
current_files.iter().map(|p| self.path_key(p)).collect();
let before_files = self.cache.files.len();
let before_parse = self.cache.parse_cache.len();
self.cache.files.retain(|k, _| current_keys.contains(k));
self.cache
.parse_cache
.retain(|k, _| current_keys.contains(k));
let pruned_files = before_files - self.cache.files.len();
let pruned_parse = before_parse - self.cache.parse_cache.len();
if pruned_files > 0 || pruned_parse > 0 {
debug!(
"Pruned {} stale file entries, {} stale parse entries",
pruned_files, pruned_parse
);
self.dirty = true;
}
}
pub fn is_graph_changed(&self, current_hash: &str) -> bool {
match &self.cache.graph.hash {
None => true,
Some(cached_hash) => cached_hash != current_hash,
}
}
pub fn compute_all_files_hash(&mut self, files: &[std::path::PathBuf]) -> String {
if let Some((count, ref hash)) = self.memoized_files_hash {
if count == files.len() {
return hash.clone();
}
}
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
let mut sorted_files: Vec<_> = files.iter().collect();
sorted_files.sort();
for path in sorted_files {
let path_bytes = path.to_string_lossy();
hasher.update(path_bytes.as_bytes());
hasher.update(self.file_hash(path).as_bytes());
}
let hash = format!("{:016x}", hasher.digest());
self.memoized_files_hash = Some((files.len(), hash.clone()));
hash
}
pub fn can_use_cached_detectors(&mut self, files: &[std::path::PathBuf]) -> bool {
let current_hash = self.compute_all_files_hash(files);
!self.is_graph_changed(¤t_hash) && !self.cache.graph.detectors.is_empty()
}
pub fn cache_graph_findings(&mut self, detector_name: &str, findings: &[Finding]) {
self.cache
.graph
.detectors
.insert(detector_name.to_string(), findings.to_vec());
self.dirty = true;
}
#[allow(dead_code)] pub fn cached_graph_findings(&self, detector_name: &str) -> Vec<Finding> {
self.cache
.graph
.detectors
.get(detector_name)
.cloned()
.unwrap_or_default()
}
pub fn all_cached_graph_findings(&self) -> Vec<Finding> {
self.cache
.graph
.detectors
.values()
.flatten()
.cloned()
.collect()
}
pub fn update_graph_hash(&mut self, hash: &str) {
self.cache.graph.hash = Some(hash.to_string());
self.dirty = true;
}
pub fn cache_score_with_subscores(&mut self, result: CachedScoreResult) {
self.cache.graph.score = Some(result);
self.dirty = true;
}
pub fn cached_score(&self) -> Option<&CachedScoreResult> {
self.cache.graph.score.as_ref()
}
pub fn has_complete_cache(&mut self, files: &[std::path::PathBuf]) -> bool {
let current_hash = self.compute_all_files_hash(files);
!self.is_graph_changed(¤t_hash)
&& !self.cache.graph.detectors.is_empty()
&& self.cache.graph.score.is_some()
}
pub fn stats(&self) -> CacheStats {
let total_findings: usize = self.cache.files.values().map(|f| f.findings.len()).sum();
let graph_findings: usize = self.cache.graph.detectors.values().map(|f| f.len()).sum();
CacheStats {
cached_files: self.cache.files.len(),
total_findings,
graph_hash: self.cache.graph.hash.clone(),
graph_detectors: self.cache.graph.detectors.len(),
graph_findings,
cache_version: self.cache.version,
}
}
#[allow(dead_code)] pub fn set_value_dependencies(
&mut self,
file: &Path,
deps: Vec<String>,
hashes: HashMap<String, u64>,
) {
let key = self.path_key(file);
if let Some(cached) = self.cache.files.get_mut(&key) {
cached.value_dependencies = deps;
cached.value_hashes = hashes;
}
}
#[allow(dead_code)] pub fn value_deps_valid(&self, file: &Path, current_hashes: &HashMap<String, u64>) -> bool {
let key = self.path_key(file);
if let Some(cached) = self.cache.files.get(&key) {
if cached.value_dependencies.is_empty() {
return true; }
for dep in &cached.value_dependencies {
let cached_hash = cached.value_hashes.get(dep);
let current_hash = current_hashes.get(dep);
if cached_hash != current_hash {
return false; }
}
true
} else {
true }
}
pub fn touch_last_used(&self) {
let marker = self.cache_dir.join(".last_used");
let _ = fs::write(&marker, chrono::Utc::now().to_rfc3339().as_bytes());
}
fn path_key(&self, path: &Path) -> String {
path.canonicalize()
.unwrap_or_else(|_| path.to_path_buf())
.to_string_lossy()
.to_string()
}
}
pub fn prune_stale_caches(max_age: std::time::Duration) {
let cache_base = match dirs::cache_dir() {
Some(d) => d.join("repotoire"),
None => return,
};
let Ok(entries) = fs::read_dir(&cache_base) else {
return;
};
let cutoff = std::time::SystemTime::now() - max_age;
for entry in entries.flatten() {
let marker = entry.path().join(".last_used");
let last_used = fs::metadata(&marker)
.and_then(|m| m.modified())
.unwrap_or(std::time::UNIX_EPOCH);
if last_used < cutoff {
let _ = fs::remove_dir_all(entry.path());
debug!("Pruned stale cache: {}", entry.path().display());
}
}
}
fn file_hash_standalone(path: &Path) -> String {
match fs::File::open(path) {
Ok(mut file) => {
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
let mut buffer = [0u8; HASH_BUFFER_SIZE];
loop {
match file.read(&mut buffer) {
Ok(0) => break,
Ok(n) => hasher.update(&buffer[..n]),
Err(_) => break,
}
}
format!("{:016x}", hasher.digest())
}
Err(_) => format!("error:{}", path.display()),
}
}
pub struct ConcurrentCacheView {
pub parse_cache: DashMap<PathBuf, Arc<ParseResult>>,
}
impl IncrementalCache {
pub fn concurrent_view(&self, files: &[PathBuf]) -> ConcurrentCacheView {
let parse_cache = DashMap::with_capacity(files.len());
for file in files {
let key = file.to_string_lossy().to_string();
if let Some(cached) = self.cache.parse_cache.get(&key) {
let current_hash = file_hash_standalone(file);
if cached.hash == current_hash {
parse_cache.insert(file.clone(), Arc::new(cached.result.clone()));
}
}
}
ConcurrentCacheView { parse_cache }
}
pub fn merge_new_parse_results(&mut self, new_results: DashMap<PathBuf, Arc<ParseResult>>) {
for (path, arc_result) in new_results.into_iter() {
let result = Arc::try_unwrap(arc_result).unwrap_or_else(|arc| (*arc).clone());
self.cache_parse_result(&path, &result);
}
}
}
impl crate::cache::CacheLayer for IncrementalCache {
fn name(&self) -> &str {
"incremental-findings"
}
fn is_populated(&self) -> bool {
self.has_cache()
}
fn invalidate_files(&mut self, changed_files: &[&std::path::Path]) {
for path in changed_files {
self.invalidate_file(path);
}
}
fn invalidate_all(&mut self) {
self.cache = CacheData::default();
self.dirty = true;
}
}
impl Drop for IncrementalCache {
fn drop(&mut self) {
if let Err(e) = self.save_cache() {
warn!("Failed to save cache on drop: {}", e);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn create_test_finding(file: &str) -> Finding {
Finding {
id: "test-1".to_string(),
detector: "TestDetector".to_string(),
severity: Severity::Medium,
title: "Test finding".to_string(),
description: "Test description".to_string(),
affected_files: vec![PathBuf::from(file)],
line_start: Some(10),
line_end: Some(20),
suggested_fix: None,
estimated_effort: None,
category: None,
cwe_id: None,
why_it_matters: None,
..Default::default()
}
}
#[test]
fn test_cache_creation() {
let tmp = TempDir::new().expect("should create temp dir");
let cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
let stats = cache.stats();
assert_eq!(stats.cached_files, 0);
assert_eq!(stats.cache_version, CACHE_VERSION);
}
#[test]
fn test_file_hash() {
let tmp = TempDir::new().expect("should create temp dir");
let file_path = tmp.path().join("test.txt");
fs::write(&file_path, "hello world").expect("should write test file");
let cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
let hash1 = cache.file_hash(&file_path);
let hash2 = cache.file_hash(&file_path);
assert_eq!(hash1, hash2);
fs::write(&file_path, "changed content").expect("should write test file");
let hash3 = cache.file_hash(&file_path);
assert_ne!(hash1, hash3);
}
#[test]
fn test_cache_findings() {
let tmp = TempDir::new().expect("should create temp dir");
let file_path = tmp.path().join("test.py");
fs::write(&file_path, "def test(): pass").expect("should write test file");
let mut cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
let finding = create_test_finding(&file_path.to_string_lossy());
cache.cache_findings(&file_path, std::slice::from_ref(&finding));
let cached = cache.cached_findings(&file_path);
assert_eq!(cached.len(), 1);
assert_eq!(cached[0].id, finding.id);
}
#[test]
fn test_changed_files() {
let tmp = TempDir::new().expect("should create temp dir");
let file1 = tmp.path().join("file1.py");
let file2 = tmp.path().join("file2.py");
fs::write(&file1, "content1").expect("should write test file");
fs::write(&file2, "content2").expect("should write test file");
let mut cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
cache.cache_findings(&file1, &[]);
let all_files = vec![file1.clone(), file2.clone()];
let changed = cache.changed_files(&all_files);
assert_eq!(changed.len(), 1);
assert_eq!(changed[0], file2);
}
#[test]
fn test_graph_cache() {
let tmp = TempDir::new().expect("should create temp dir");
let mut cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
let finding = create_test_finding("test.py");
cache.cache_graph_findings("TestDetector", &[finding]);
cache.update_graph_hash("hash123");
assert!(!cache.is_graph_changed("hash123"));
assert!(cache.is_graph_changed("different_hash"));
let cached = cache.cached_graph_findings("TestDetector");
assert_eq!(cached.len(), 1);
}
#[test]
fn test_invalidation() {
let tmp = TempDir::new().expect("should create temp dir");
let file_path = tmp.path().join("test.py");
fs::write(&file_path, "content").expect("should write test file");
let mut cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
cache.cache_findings(&file_path, &[create_test_finding("test.py")]);
assert_eq!(cache.stats().cached_files, 1);
cache.invalidate_file(&file_path);
assert_eq!(cache.stats().cached_files, 0);
cache.cache_findings(&file_path, &[create_test_finding("test.py")]);
cache.invalidate_all();
assert_eq!(cache.stats().cached_files, 0);
}
#[test]
fn test_incremental_cache_implements_cache_layer() {
use crate::cache::CacheLayer;
let tmp = TempDir::new().expect("should create temp dir");
let mut cache =
IncrementalCache::new(tmp.path(), &crate::config::ProjectConfig::default(), false);
assert_eq!(cache.name(), "incremental-findings");
assert!(!cache.is_populated());
let file_a = tmp.path().join("a.py");
let file_b = tmp.path().join("b.py");
fs::write(&file_a, "def foo(): pass").expect("should write test file");
fs::write(&file_b, "def bar(): pass").expect("should write test file");
cache.cache_findings(&file_a, &[create_test_finding("a.py")]);
cache.cache_findings(&file_b, &[create_test_finding("b.py")]);
assert!(cache.is_populated());
assert_eq!(cache.stats().cached_files, 2);
let path_a_ref: &Path = &file_a;
cache.invalidate_files(&[path_a_ref]);
assert_eq!(cache.stats().cached_files, 1);
assert!(cache.is_populated());
cache.invalidate_all();
assert!(!cache.is_populated());
assert_eq!(cache.stats().cached_files, 0);
}
#[test]
fn test_cached_finding_round_trip_preserves_threshold_metadata() {
use crate::models::{Finding, Severity};
use std::collections::BTreeMap;
let mut meta = BTreeMap::new();
meta.insert("threshold_source".to_string(), "adaptive".to_string());
meta.insert("effective_threshold".to_string(), "15".to_string());
let finding = Finding {
id: "rt-1".into(),
detector: "TestDetector".into(),
severity: Severity::High,
title: "Test".into(),
description: "Desc".into(),
confidence: Some(0.85),
threshold_metadata: meta,
..Default::default()
};
let bytes = bitcode::serialize(&finding).expect("serialize");
let restored: Finding = bitcode::deserialize(&bytes).expect("deserialize");
assert_eq!(restored.id, "rt-1");
assert_eq!(restored.confidence, Some(0.85));
assert_eq!(
restored
.threshold_metadata
.get("effective_threshold")
.expect("metadata key should exist"),
"15"
);
assert_eq!(
restored
.threshold_metadata
.get("threshold_source")
.expect("key should exist"),
"adaptive"
);
}
#[test]
fn test_cache_value_dependencies_valid() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let mut cache =
IncrementalCache::new(&cache_dir, &crate::config::ProjectConfig::default(), false);
let file = dir.path().join("handler.py");
fs::write(&file, "import config").unwrap();
cache.cache_findings(&file, &[]);
let deps = vec!["config.TIMEOUT".to_string()];
let mut hashes = HashMap::new();
hashes.insert("config.TIMEOUT".to_string(), 12345u64);
cache.set_value_dependencies(&file, deps, hashes);
let mut current = HashMap::new();
current.insert("config.TIMEOUT".to_string(), 12345u64);
assert!(cache.value_deps_valid(&file, ¤t));
}
#[test]
fn test_cache_invalidates_on_value_dependency_change() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let mut cache =
IncrementalCache::new(&cache_dir, &crate::config::ProjectConfig::default(), false);
let file = dir.path().join("handler.py");
fs::write(&file, "import config").unwrap();
cache.cache_findings(&file, &[]);
let deps = vec!["config.TIMEOUT".to_string()];
let mut hashes = HashMap::new();
hashes.insert("config.TIMEOUT".to_string(), 12345u64);
cache.set_value_dependencies(&file, deps, hashes);
let mut current = HashMap::new();
current.insert("config.TIMEOUT".to_string(), 99999u64);
assert!(!cache.value_deps_valid(&file, ¤t));
}
#[test]
fn test_cache_no_dependencies_always_valid() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let mut cache =
IncrementalCache::new(&cache_dir, &crate::config::ProjectConfig::default(), false);
let file = dir.path().join("simple.py");
fs::write(&file, "x = 1").unwrap();
cache.cache_findings(&file, &[]);
let current = HashMap::new();
assert!(cache.value_deps_valid(&file, ¤t));
}
#[test]
fn test_cache_missing_dependency_in_current_invalidates() {
let dir = TempDir::new().unwrap();
let cache_dir = dir.path().join("cache");
std::fs::create_dir_all(&cache_dir).unwrap();
let mut cache =
IncrementalCache::new(&cache_dir, &crate::config::ProjectConfig::default(), false);
let file = dir.path().join("handler.py");
fs::write(&file, "import config").unwrap();
cache.cache_findings(&file, &[]);
let deps = vec!["config.TIMEOUT".to_string()];
let mut hashes = HashMap::new();
hashes.insert("config.TIMEOUT".to_string(), 12345u64);
cache.set_value_dependencies(&file, deps, hashes);
let current = HashMap::new();
assert!(!cache.value_deps_valid(&file, ¤t));
}
}
#[cfg(test)]
mod fingerprint_tests {
use super::*;
#[test]
fn test_compute_fingerprint_deterministic() {
let config = crate::config::ProjectConfig::default();
let fp1 = compute_fingerprint(12345, &config, false);
let fp2 = compute_fingerprint(12345, &config, false);
assert_eq!(fp1, fp2, "Same inputs should produce same fingerprint");
}
#[test]
fn test_compute_fingerprint_changes_on_binary_hash() {
let config = crate::config::ProjectConfig::default();
let fp1 = compute_fingerprint(12345, &config, false);
let fp2 = compute_fingerprint(99999, &config, false);
assert_ne!(fp1, fp2, "Different binary hash should change fingerprint");
}
#[test]
fn test_compute_fingerprint_changes_on_mode() {
let config = crate::config::ProjectConfig::default();
let fp1 = compute_fingerprint(12345, &config, false);
let fp2 = compute_fingerprint(12345, &config, true);
assert_ne!(
fp1, fp2,
"Different all_detectors should change fingerprint"
);
}
#[test]
fn test_sort_json_keys_deterministic() {
let json1 = serde_json::json!({"b": 2, "a": 1, "c": {"z": 3, "y": 4}});
let json2 = serde_json::json!({"c": {"y": 4, "z": 3}, "a": 1, "b": 2});
let s1 = serde_json::to_string(&sort_json_keys(json1)).unwrap();
let s2 = serde_json::to_string(&sort_json_keys(json2)).unwrap();
assert_eq!(
s1, s2,
"Same data with different key order should produce same output"
);
}
}