ricecoder_research/
change_detector.rs

1//! File change detection for cache invalidation
2
3use crate::error::ResearchError;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8/// Tracks file modifications for cache invalidation
9#[derive(Debug, Clone)]
10pub struct ChangeDetector {
11    /// Map of file paths to their last known modification times
12    file_mtimes: HashMap<PathBuf, SystemTime>,
13}
14
15impl ChangeDetector {
16    /// Create a new change detector
17    pub fn new() -> Self {
18        Self {
19            file_mtimes: HashMap::new(),
20        }
21    }
22
23    /// Record the current modification times of files in a directory
24    pub fn record_mtimes(&mut self, root: &Path) -> Result<(), ResearchError> {
25        self.file_mtimes.clear();
26        self.scan_directory(root)?;
27        Ok(())
28    }
29
30    /// Recursively scan a directory and record file modification times
31    fn scan_directory(&mut self, path: &Path) -> Result<(), ResearchError> {
32        if !path.exists() {
33            return Err(ResearchError::ProjectNotFound {
34                path: path.to_path_buf(),
35                reason: "Directory does not exist".to_string(),
36            });
37        }
38
39        for entry in std::fs::read_dir(path).map_err(|e| ResearchError::IoError {
40            reason: format!("Failed to read directory {}: {}", path.display(), e),
41        })? {
42            let entry = entry.map_err(|e| ResearchError::IoError {
43                reason: format!("Failed to read directory entry: {}", e),
44            })?;
45
46            let path = entry.path();
47
48            // Skip hidden files and common ignore patterns
49            if self.should_skip(&path) {
50                continue;
51            }
52
53            if path.is_dir() {
54                self.scan_directory(&path)?;
55            } else {
56                // Record file modification time
57                if let Ok(metadata) = std::fs::metadata(&path) {
58                    if let Ok(modified) = metadata.modified() {
59                        self.file_mtimes.insert(path, modified);
60                    }
61                }
62            }
63        }
64
65        Ok(())
66    }
67
68    /// Check if a path should be skipped during scanning
69    pub fn should_skip(&self, path: &Path) -> bool {
70        // Skip hidden files and directories
71        if let Some(file_name) = path.file_name() {
72            if let Some(name_str) = file_name.to_str() {
73                if name_str.starts_with('.') {
74                    return true;
75                }
76            }
77        }
78
79        // Skip common directories
80        if let Some(file_name) = path.file_name() {
81            if let Some(
82                _name_str @ ("node_modules" | "target" | ".git" | ".venv" | "venv" | "__pycache__"
83                | ".pytest_cache" | "dist" | "build"),
84            ) = file_name.to_str()
85            {
86                return true;
87            }
88        }
89
90        false
91    }
92
93    /// Detect changes since the last recording
94    pub fn detect_changes(&self, root: &Path) -> Result<ChangeDetection, ResearchError> {
95        let mut current_mtimes = HashMap::new();
96        self.collect_mtimes(root, &mut current_mtimes)?;
97
98        let mut added = Vec::new();
99        let mut modified = Vec::new();
100        let mut deleted = Vec::new();
101
102        // Find added and modified files
103        for (path, current_mtime) in &current_mtimes {
104            match self.file_mtimes.get(path) {
105                None => added.push(path.clone()),
106                Some(recorded_mtime) if current_mtime > recorded_mtime => {
107                    modified.push(path.clone());
108                }
109                _ => {}
110            }
111        }
112
113        // Find deleted files
114        for path in self.file_mtimes.keys() {
115            if !current_mtimes.contains_key(path) {
116                deleted.push(path.clone());
117            }
118        }
119
120        let has_changes = !added.is_empty() || !modified.is_empty() || !deleted.is_empty();
121
122        Ok(ChangeDetection {
123            added,
124            modified,
125            deleted,
126            has_changes,
127        })
128    }
129
130    /// Collect modification times from a directory
131    fn collect_mtimes(
132        &self,
133        path: &Path,
134        mtimes: &mut HashMap<PathBuf, SystemTime>,
135    ) -> Result<(), ResearchError> {
136        if !path.exists() {
137            return Ok(());
138        }
139
140        for entry in std::fs::read_dir(path).map_err(|e| ResearchError::IoError {
141            reason: format!("Failed to read directory {}: {}", path.display(), e),
142        })? {
143            let entry = entry.map_err(|e| ResearchError::IoError {
144                reason: format!("Failed to read directory entry: {}", e),
145            })?;
146
147            let path = entry.path();
148
149            if self.should_skip(&path) {
150                continue;
151            }
152
153            if path.is_dir() {
154                self.collect_mtimes(&path, mtimes)?;
155            } else if let Ok(metadata) = std::fs::metadata(&path) {
156                if let Ok(modified) = metadata.modified() {
157                    mtimes.insert(path, modified);
158                }
159            }
160        }
161
162        Ok(())
163    }
164
165    /// Get the recorded modification times
166    pub fn recorded_mtimes(&self) -> &HashMap<PathBuf, SystemTime> {
167        &self.file_mtimes
168    }
169
170    /// Get the number of tracked files
171    pub fn tracked_file_count(&self) -> usize {
172        self.file_mtimes.len()
173    }
174
175    /// Clear all recorded modification times
176    pub fn clear(&mut self) {
177        self.file_mtimes.clear();
178    }
179}
180
181impl Default for ChangeDetector {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187/// Result of change detection
188#[derive(Debug, Clone)]
189pub struct ChangeDetection {
190    /// Files that were added
191    pub added: Vec<PathBuf>,
192    /// Files that were modified
193    pub modified: Vec<PathBuf>,
194    /// Files that were deleted
195    pub deleted: Vec<PathBuf>,
196    /// Whether any changes were detected
197    pub has_changes: bool,
198}
199
200impl ChangeDetection {
201    /// Get all changed files (added + modified + deleted)
202    pub fn all_changed(&self) -> Vec<PathBuf> {
203        let mut all = self.added.clone();
204        all.extend(self.modified.clone());
205        all.extend(self.deleted.clone());
206        all
207    }
208
209    /// Get the total number of changes
210    pub fn change_count(&self) -> usize {
211        self.added.len() + self.modified.len() + self.deleted.len()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use std::fs;
219    use tempfile::TempDir;
220
221    #[test]
222    fn test_change_detector_creation() {
223        let detector = ChangeDetector::new();
224        assert_eq!(detector.tracked_file_count(), 0);
225    }
226
227    #[test]
228    fn test_change_detector_skip_hidden_files() {
229        let detector = ChangeDetector::new();
230        assert!(detector.should_skip(Path::new(".hidden")));
231        assert!(detector.should_skip(Path::new(".git")));
232        assert!(detector.should_skip(Path::new("node_modules")));
233        assert!(detector.should_skip(Path::new("target")));
234        assert!(!detector.should_skip(Path::new("src")));
235    }
236
237    #[test]
238    fn test_change_detection_no_changes() -> Result<(), Box<dyn std::error::Error>> {
239        let temp_dir = TempDir::new()?;
240        let test_file = temp_dir.path().join("test.txt");
241        fs::write(&test_file, "content")?;
242
243        let mut detector = ChangeDetector::new();
244        detector.record_mtimes(temp_dir.path())?;
245
246        let changes = detector.detect_changes(temp_dir.path())?;
247        assert!(!changes.has_changes);
248        assert_eq!(changes.change_count(), 0);
249
250        Ok(())
251    }
252
253    #[test]
254    fn test_change_detection_added_file() -> Result<(), Box<dyn std::error::Error>> {
255        let temp_dir = TempDir::new()?;
256        let test_file1 = temp_dir.path().join("test1.txt");
257        fs::write(&test_file1, "content1")?;
258
259        let mut detector = ChangeDetector::new();
260        detector.record_mtimes(temp_dir.path())?;
261
262        // Add a new file
263        let test_file2 = temp_dir.path().join("test2.txt");
264        fs::write(&test_file2, "content2")?;
265
266        let changes = detector.detect_changes(temp_dir.path())?;
267        assert!(changes.has_changes);
268        assert_eq!(changes.added.len(), 1);
269        assert_eq!(changes.modified.len(), 0);
270        assert_eq!(changes.deleted.len(), 0);
271
272        Ok(())
273    }
274
275    #[test]
276    fn test_change_detection_modified_file() -> Result<(), Box<dyn std::error::Error>> {
277        let temp_dir = TempDir::new()?;
278        let test_file = temp_dir.path().join("test.txt");
279        fs::write(&test_file, "content")?;
280
281        let mut detector = ChangeDetector::new();
282        detector.record_mtimes(temp_dir.path())?;
283
284        // Modify the file (with a small delay to ensure mtime changes)
285        std::thread::sleep(std::time::Duration::from_millis(10));
286        fs::write(&test_file, "modified content")?;
287
288        let changes = detector.detect_changes(temp_dir.path())?;
289        assert!(changes.has_changes);
290        assert_eq!(changes.added.len(), 0);
291        assert_eq!(changes.modified.len(), 1);
292        assert_eq!(changes.deleted.len(), 0);
293
294        Ok(())
295    }
296
297    #[test]
298    fn test_change_detection_deleted_file() -> Result<(), Box<dyn std::error::Error>> {
299        let temp_dir = TempDir::new()?;
300        let test_file = temp_dir.path().join("test.txt");
301        fs::write(&test_file, "content")?;
302
303        let mut detector = ChangeDetector::new();
304        detector.record_mtimes(temp_dir.path())?;
305
306        // Delete the file
307        fs::remove_file(&test_file)?;
308
309        let changes = detector.detect_changes(temp_dir.path())?;
310        assert!(changes.has_changes);
311        assert_eq!(changes.added.len(), 0);
312        assert_eq!(changes.modified.len(), 0);
313        assert_eq!(changes.deleted.len(), 1);
314
315        Ok(())
316    }
317
318    #[test]
319    fn test_change_detection_all_changed() {
320        let change_detection = ChangeDetection {
321            added: vec![PathBuf::from("added.txt")],
322            modified: vec![PathBuf::from("modified.txt")],
323            deleted: vec![PathBuf::from("deleted.txt")],
324            has_changes: true,
325        };
326
327        let all_changed = change_detection.all_changed();
328        assert_eq!(all_changed.len(), 3);
329        assert_eq!(change_detection.change_count(), 3);
330    }
331
332    #[test]
333    fn test_change_detector_clear() {
334        let mut detector = ChangeDetector::new();
335        detector
336            .file_mtimes
337            .insert(PathBuf::from("test.txt"), SystemTime::now());
338        assert_eq!(detector.tracked_file_count(), 1);
339
340        detector.clear();
341        assert_eq!(detector.tracked_file_count(), 0);
342    }
343}