Skip to main content

forge_guard/utils/
cache.rs

1//! Caching — stores audit results and analysis data to speed up re-runs.
2
3use crate::core::{ForgeGuardError, ProjectConfig};
4use serde::Serialize;
5use sha2::{Digest, Sha256};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::SystemTime;
9
10/// A simple filesystem-based cache for audit results.
11#[allow(dead_code)]
12pub struct Cache {
13    enabled: bool,
14    cache_dir: PathBuf,
15    memory_cache: HashMap<String, String>,
16    max_size: u64,
17    ttl_seconds: u64,
18}
19
20impl Cache {
21    /// Create a new cache instance.
22    pub fn new(config: &ProjectConfig) -> Result<Self, ForgeGuardError> {
23        let cache_dir = config.project_root.join(&config.cache.directory);
24        let enabled = config.cache.enabled;
25
26        if enabled {
27            std::fs::create_dir_all(&cache_dir).ok();
28            // Clean old cache entries
29            Self::clean_expired(&cache_dir, config.cache.ttl_seconds);
30        }
31
32        Ok(Self {
33            enabled,
34            cache_dir,
35            memory_cache: HashMap::new(),
36            max_size: config.cache.max_size_mb,
37            ttl_seconds: config.cache.ttl_seconds,
38        })
39    }
40
41    /// Store a value in the cache.
42    pub fn store<T: Serialize>(&self, key: &str, value: &T) -> Result<(), ForgeGuardError> {
43        if !self.enabled {
44            return Ok(());
45        }
46
47        let json = serde_json::to_string(value)?;
48        let cache_file = self.cache_path(key);
49
50        // Also store in memory cache
51        // Can't modify memory_cache since we don't have &mut self
52        // Use filesystem only for now
53
54        std::fs::write(&cache_file, &json)?;
55        // Set file modification time for TTL
56        let _ = filetime::set_file_mtime(&cache_file, filetime::FileTime::now());
57
58        Ok(())
59    }
60
61    /// Load a value from the cache.
62    pub fn load<T: serde::de::DeserializeOwned>(
63        &self,
64        key: &str,
65    ) -> Result<Option<T>, ForgeGuardError> {
66        if !self.enabled {
67            return Ok(None);
68        }
69
70        let cache_file = self.cache_path(key);
71        if !cache_file.exists() {
72            return Ok(None);
73        }
74
75        // Check TTL
76        if let Ok(metadata) = std::fs::metadata(&cache_file) {
77            if let Ok(modified) = metadata.modified() {
78                let age = SystemTime::now()
79                    .duration_since(modified)
80                    .unwrap_or_default()
81                    .as_secs();
82                if age > self.ttl_seconds {
83                    std::fs::remove_file(&cache_file).ok();
84                    return Ok(None);
85                }
86            }
87        }
88
89        let content = std::fs::read_to_string(&cache_file)?;
90        let value = serde_json::from_str(&content)?;
91        Ok(Some(value))
92    }
93
94    /// Check if a key exists in the cache and is fresh.
95    pub fn has(&self, key: &str) -> bool {
96        if !self.enabled {
97            return false;
98        }
99        let cache_file = self.cache_path(key);
100        if !cache_file.exists() {
101            return false;
102        }
103        if let Ok(metadata) = std::fs::metadata(&cache_file) {
104            if let Ok(modified) = metadata.modified() {
105                let age = SystemTime::now()
106                    .duration_since(modified)
107                    .unwrap_or_default()
108                    .as_secs();
109                return age <= self.ttl_seconds;
110            }
111        }
112        false
113    }
114
115    /// Clear all cached entries.
116    pub fn clear(&self) -> Result<(), ForgeGuardError> {
117        if self.cache_dir.exists() {
118            std::fs::remove_dir_all(&self.cache_dir)?;
119            std::fs::create_dir_all(&self.cache_dir)?;
120        }
121        Ok(())
122    }
123
124    /// Get cache size in bytes.
125    pub fn size(&self) -> u64 {
126        let mut total = 0;
127        if let Ok(entries) = std::fs::read_dir(&self.cache_dir) {
128            for entry in entries.flatten() {
129                if let Ok(metadata) = entry.metadata() {
130                    total += metadata.len();
131                }
132            }
133        }
134        total
135    }
136
137    fn cache_path(&self, key: &str) -> PathBuf {
138        // Sanitize the key for use as a filename
139        let sanitized: String = key
140            .chars()
141            .map(|c| {
142                if c.is_alphanumeric() || c == '-' || c == '_' {
143                    c
144                } else {
145                    '_'
146                }
147            })
148            .collect();
149        self.cache_dir.join(format!("{}.json", sanitized))
150    }
151
152    /// Compute a content hash for incremental file analysis.
153    /// Returns a hex string of the file's SHA-256 hash.
154    /// Used to skip re-analysis of unchanged files.
155    pub fn file_hash(path: &Path) -> Result<String, ForgeGuardError> {
156        let content = std::fs::read(path)?;
157        let hash = Sha256::digest(&content);
158        Ok(hex::encode(hash))
159    }
160
161    /// Check if a file has changed since the last analysis using content hashing.
162    /// Returns `true` if the file is unchanged (cache hit).
163    pub fn is_file_unchanged(&self, file_path: &Path) -> bool {
164        if !self.enabled {
165            return false;
166        }
167        let hash = match Self::file_hash(file_path) {
168            Ok(h) => h,
169            Err(_) => return false,
170        };
171        let key = &format!("file_hash_{}", file_path.to_string_lossy());
172        match self.load::<String>(key) {
173            Ok(Some(cached)) => cached == hash,
174            _ => false,
175        }
176    }
177
178    /// Record a file hash for future incremental analysis.
179    /// Call this after analyzing a file to mark it as processed.
180    pub fn record_file_hash(&self, file_path: &Path) -> Result<(), ForgeGuardError> {
181        if !self.enabled {
182            return Ok(());
183        }
184        let hash = Self::file_hash(file_path)?;
185        let key = &format!("file_hash_{}", file_path.to_string_lossy());
186        self.store(key, &hash)
187    }
188
189    /// Filter out files that have not changed since the last analysis.
190    /// Returns only the files that need (re-)analysis.
191    pub fn filter_changed_files(&self, files: &[PathBuf]) -> Vec<PathBuf> {
192        if !self.enabled {
193            return files.to_vec();
194        }
195        files
196            .iter()
197            .filter(|f| !self.is_file_unchanged(f))
198            .cloned()
199            .collect()
200    }
201
202    fn clean_expired(cache_dir: &PathBuf, ttl_seconds: u64) {
203        if let Ok(entries) = std::fs::read_dir(cache_dir) {
204            for entry in entries.flatten() {
205                if let Ok(metadata) = entry.metadata() {
206                    if let Ok(modified) = metadata.modified() {
207                        let age = SystemTime::now()
208                            .duration_since(modified)
209                            .unwrap_or_default()
210                            .as_secs();
211                        if age > ttl_seconds {
212                            std::fs::remove_file(entry.path()).ok();
213                        }
214                    }
215                }
216            }
217        }
218    }
219}
220
221// Adding filetime dependency is optional; use a simple approach
222mod filetime {
223    use std::fs;
224    use std::time::SystemTime;
225
226    pub struct FileTime;
227    impl FileTime {
228        pub fn now() -> SystemTime {
229            SystemTime::now()
230        }
231    }
232
233    pub fn set_file_mtime(path: &std::path::Path, _time: SystemTime) -> std::io::Result<()> {
234        // Touch the file to update its mtime
235        let _ = fs::File::options().write(true).open(path)?;
236        Ok(())
237    }
238}