ricecoder_research/
change_detector.rs1use crate::error::ResearchError;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::time::SystemTime;
7
8#[derive(Debug, Clone)]
10pub struct ChangeDetector {
11 file_mtimes: HashMap<PathBuf, SystemTime>,
13}
14
15impl ChangeDetector {
16 pub fn new() -> Self {
18 Self {
19 file_mtimes: HashMap::new(),
20 }
21 }
22
23 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 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 if self.should_skip(&path) {
50 continue;
51 }
52
53 if path.is_dir() {
54 self.scan_directory(&path)?;
55 } else {
56 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 pub fn should_skip(&self, path: &Path) -> bool {
70 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 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 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 for (path, current_mtime) in ¤t_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 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 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 pub fn recorded_mtimes(&self) -> &HashMap<PathBuf, SystemTime> {
167 &self.file_mtimes
168 }
169
170 pub fn tracked_file_count(&self) -> usize {
172 self.file_mtimes.len()
173 }
174
175 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#[derive(Debug, Clone)]
189pub struct ChangeDetection {
190 pub added: Vec<PathBuf>,
192 pub modified: Vec<PathBuf>,
194 pub deleted: Vec<PathBuf>,
196 pub has_changes: bool,
198}
199
200impl ChangeDetection {
201 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 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 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 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 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}