Skip to main content

null_e/cache/
mod.rs

1//! Scan result caching for faster subsequent scans
2//!
3//! This module provides intelligent caching of scan results with mtime-based invalidation.
4//! When a directory's modification time hasn't changed, we can skip rescanning it.
5
6use crate::core::Project;
7use crate::error::{DevSweepError, Result};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::{Duration, SystemTime, UNIX_EPOCH};
13
14/// Cache entry for a single project
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CachedProject {
17    /// The project data
18    pub project: Project,
19    /// Modification time of the project root when cached
20    pub root_mtime: u64,
21    /// When this entry was cached
22    pub cached_at: u64,
23}
24
25/// Cache entry for a scanned directory
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CachedDirectory {
28    /// Modification time when last scanned
29    pub mtime: u64,
30    /// Project IDs found in this directory (empty if no project)
31    pub project_roots: Vec<PathBuf>,
32}
33
34/// The scan cache
35#[derive(Debug, Clone, Serialize, Deserialize, Default)]
36pub struct ScanCache {
37    /// Version for cache invalidation on format changes
38    pub version: u32,
39    /// When the cache was last updated
40    pub updated_at: u64,
41    /// Cached projects by their root path
42    pub projects: HashMap<PathBuf, CachedProject>,
43    /// Cached directory scan info
44    pub directories: HashMap<PathBuf, CachedDirectory>,
45}
46
47impl ScanCache {
48    /// Current cache version - bump this when format changes
49    pub const VERSION: u32 = 1;
50
51    /// Cache TTL - invalidate after 24 hours regardless
52    pub const TTL_SECS: u64 = 24 * 60 * 60;
53
54    /// Create a new empty cache
55    pub fn new() -> Self {
56        Self {
57            version: Self::VERSION,
58            updated_at: current_timestamp(),
59            projects: HashMap::new(),
60            directories: HashMap::new(),
61        }
62    }
63
64    /// Check if a project is still valid in cache
65    pub fn get_valid_project(&self, root: &Path) -> Option<&CachedProject> {
66        let cached = self.projects.get(root)?;
67
68        // Check if mtime has changed
69        let current_mtime = get_mtime(root).ok()?;
70        if cached.root_mtime != current_mtime {
71            return None; // Directory was modified
72        }
73
74        // Check TTL
75        let now = current_timestamp();
76        if now - cached.cached_at > Self::TTL_SECS {
77            return None; // Cache expired
78        }
79
80        Some(cached)
81    }
82
83    /// Check if a directory needs to be rescanned
84    pub fn directory_needs_rescan(&self, path: &Path) -> bool {
85        let Some(cached) = self.directories.get(path) else {
86            return true; // Never scanned
87        };
88
89        // Check if mtime has changed
90        let Ok(current_mtime) = get_mtime(path) else {
91            return true; // Can't read mtime, rescan
92        };
93
94        cached.mtime != current_mtime
95    }
96
97    /// Cache a project
98    pub fn cache_project(&mut self, project: Project) {
99        let root = project.root.clone();
100        let mtime = get_mtime(&root).unwrap_or(0);
101
102        self.projects.insert(root, CachedProject {
103            project,
104            root_mtime: mtime,
105            cached_at: current_timestamp(),
106        });
107    }
108
109    /// Cache a directory scan result
110    pub fn cache_directory(&mut self, path: PathBuf, project_roots: Vec<PathBuf>) {
111        let mtime = get_mtime(&path).unwrap_or(0);
112        self.directories.insert(path, CachedDirectory {
113            mtime,
114            project_roots,
115        });
116    }
117
118    /// Update the cache timestamp
119    pub fn touch(&mut self) {
120        self.updated_at = current_timestamp();
121    }
122
123    /// Check if the entire cache is valid
124    pub fn is_valid(&self) -> bool {
125        // Version check
126        if self.version != Self::VERSION {
127            return false;
128        }
129
130        // TTL check
131        let now = current_timestamp();
132        now - self.updated_at < Self::TTL_SECS
133    }
134
135    /// Get all valid cached projects
136    pub fn get_all_valid_projects(&self) -> Vec<Project> {
137        self.projects
138            .iter()
139            .filter_map(|(path, _cached)| {
140                self.get_valid_project(path).map(|c| c.project.clone())
141            })
142            .collect()
143    }
144
145    /// Number of cached projects
146    pub fn project_count(&self) -> usize {
147        self.projects.len()
148    }
149
150    /// Clear the cache
151    pub fn clear(&mut self) {
152        self.projects.clear();
153        self.directories.clear();
154        self.updated_at = current_timestamp();
155    }
156}
157
158/// Get the default cache file path
159pub fn default_cache_path() -> Result<PathBuf> {
160    let cache_dir = dirs::cache_dir()
161        .ok_or_else(|| DevSweepError::Config("Could not find cache directory".into()))?;
162
163    let devsweep_cache = cache_dir.join("devsweep");
164    if !devsweep_cache.exists() {
165        fs::create_dir_all(&devsweep_cache)?;
166    }
167
168    Ok(devsweep_cache.join("scan_cache.json"))
169}
170
171/// Load the cache from disk
172pub fn load_cache() -> Result<ScanCache> {
173    let path = default_cache_path()?;
174
175    if !path.exists() {
176        return Ok(ScanCache::new());
177    }
178
179    let content = fs::read_to_string(&path)?;
180    let cache: ScanCache = serde_json::from_str(&content)
181        .map_err(|e| DevSweepError::Config(format!("Invalid cache file: {}", e)))?;
182
183    // Check version
184    if cache.version != ScanCache::VERSION {
185        return Ok(ScanCache::new()); // Version mismatch, start fresh
186    }
187
188    Ok(cache)
189}
190
191/// Save the cache to disk
192pub fn save_cache(cache: &ScanCache) -> Result<()> {
193    let path = default_cache_path()?;
194    let content = serde_json::to_string_pretty(cache)?;
195    fs::write(&path, content)?;
196    Ok(())
197}
198
199/// Get modification time of a path as unix timestamp
200fn get_mtime(path: &Path) -> Result<u64> {
201    let metadata = fs::metadata(path)?;
202    let mtime = metadata.modified()?;
203    Ok(mtime
204        .duration_since(UNIX_EPOCH)
205        .unwrap_or(Duration::ZERO)
206        .as_secs())
207}
208
209/// Get current timestamp
210fn current_timestamp() -> u64 {
211    SystemTime::now()
212        .duration_since(UNIX_EPOCH)
213        .unwrap_or(Duration::ZERO)
214        .as_secs()
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use tempfile::TempDir;
221
222    #[test]
223    fn test_cache_creation() {
224        let cache = ScanCache::new();
225        assert_eq!(cache.version, ScanCache::VERSION);
226        assert!(cache.projects.is_empty());
227    }
228
229    #[test]
230    fn test_cache_save_load() {
231        let temp = TempDir::new().unwrap();
232        let cache_path = temp.path().join("test_cache.json");
233
234        let mut cache = ScanCache::new();
235        // Add a dummy project would go here
236
237        let content = serde_json::to_string(&cache).unwrap();
238        fs::write(&cache_path, &content).unwrap();
239
240        let loaded: ScanCache = serde_json::from_str(&fs::read_to_string(&cache_path).unwrap()).unwrap();
241        assert_eq!(loaded.version, cache.version);
242    }
243}