1use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9use std::time::SystemTime;
10
11use crate::error::Result;
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FileMetadata {
16 pub modified: SystemTime,
18}
19
20#[derive(Debug, Clone)]
25pub struct SpecSnapshot {
26 pub timestamp: SystemTime,
28 pub files: HashMap<PathBuf, FileMetadata>,
30}
31
32impl SpecSnapshot {
33 pub fn capture() -> Result<Self> {
42 let timestamp = SystemTime::now();
43 let mut files = HashMap::new();
44
45 if let Ok(spec_dir) = crate::config::spec_dir() {
47 collect_md_files(&spec_dir, &mut files);
48 }
49
50 if let Ok(cwd) = std::env::current_dir() {
52 collect_md_files(&cwd, &mut files);
53 }
54
55 Ok(Self { timestamp, files })
56 }
57
58 #[cfg(test)]
60 pub fn capture_from_dirs(dirs: &[PathBuf]) -> Self {
61 let timestamp = SystemTime::now();
62 let mut files = HashMap::new();
63
64 for dir in dirs {
65 collect_md_files(dir, &mut files);
66 }
67
68 Self { timestamp, files }
69 }
70
71 #[cfg(test)]
73 pub fn empty() -> Self {
74 Self {
75 timestamp: SystemTime::now(),
76 files: HashMap::new(),
77 }
78 }
79
80 pub fn len(&self) -> usize {
82 self.files.len()
83 }
84
85 pub fn is_empty(&self) -> bool {
87 self.files.is_empty()
88 }
89
90 pub fn contains(&self, path: &PathBuf) -> bool {
92 self.files.contains_key(path)
93 }
94
95 pub fn get(&self, path: &PathBuf) -> Option<&FileMetadata> {
97 self.files.get(path)
98 }
99
100 pub fn detect_new_files(&self) -> Result<Vec<PathBuf>> {
109 let mut current_files = HashMap::new();
110
111 if let Ok(spec_dir) = crate::config::spec_dir() {
113 collect_md_files(&spec_dir, &mut current_files);
114 }
115
116 if let Ok(cwd) = std::env::current_dir() {
118 collect_md_files(&cwd, &mut current_files);
119 }
120
121 let mut new_files = Vec::new();
122
123 for (path, metadata) in current_files {
124 match self.files.get(&path) {
125 None => {
127 new_files.push(path);
128 }
129 Some(old_metadata) => {
131 if metadata.modified > self.timestamp
132 && metadata.modified != old_metadata.modified
133 {
134 new_files.push(path);
135 }
136 }
137 }
138 }
139
140 new_files.sort();
142
143 Ok(new_files)
144 }
145
146 #[cfg(test)]
148 pub fn detect_new_files_from_dirs(&self, dirs: &[PathBuf]) -> Vec<PathBuf> {
149 let mut current_files = HashMap::new();
150
151 for dir in dirs {
152 collect_md_files(dir, &mut current_files);
153 }
154
155 let mut new_files = Vec::new();
156
157 for (path, metadata) in current_files {
158 match self.files.get(&path) {
159 None => {
160 new_files.push(path);
161 }
162 Some(old_metadata) => {
163 if metadata.modified > self.timestamp
164 && metadata.modified != old_metadata.modified
165 {
166 new_files.push(path);
167 }
168 }
169 }
170 }
171
172 new_files.sort();
173 new_files
174 }
175}
176
177fn collect_md_files(dir: &PathBuf, files: &mut HashMap<PathBuf, FileMetadata>) {
182 if !dir.exists() || !dir.is_dir() {
183 return;
184 }
185
186 let entries = match fs::read_dir(dir) {
187 Ok(entries) => entries,
188 Err(_) => return,
189 };
190
191 for entry in entries.flatten() {
192 let path = entry.path();
193
194 if !path.is_file() {
196 continue;
197 }
198 let extension = path.extension().and_then(|e| e.to_str());
199 if extension != Some("md") {
200 continue;
201 }
202
203 let metadata = match fs::metadata(&path) {
205 Ok(m) => m,
206 Err(_) => continue,
207 };
208 let modified = match metadata.modified() {
209 Ok(t) => t,
210 Err(_) => continue,
211 };
212
213 let canonical = path.canonicalize().unwrap_or(path);
215 files.insert(canonical, FileMetadata { modified });
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::thread;
223 use std::time::Duration;
224 use tempfile::TempDir;
225
226 #[test]
227 fn test_empty_snapshot() {
228 let snapshot = SpecSnapshot::empty();
229 assert!(snapshot.is_empty());
230 assert_eq!(snapshot.len(), 0);
231 }
232
233 #[test]
234 fn test_capture_from_nonexistent_directory() {
235 let nonexistent = PathBuf::from("/this/path/does/not/exist");
236 let snapshot = SpecSnapshot::capture_from_dirs(&[nonexistent]);
237
238 assert!(snapshot.is_empty());
239 assert_eq!(snapshot.len(), 0);
240 }
241
242 #[test]
243 fn test_capture_from_empty_directory() {
244 let temp_dir = TempDir::new().unwrap();
245 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
246
247 assert!(snapshot.is_empty());
248 }
249
250 #[test]
251 fn test_capture_md_files_only() {
252 let temp_dir = TempDir::new().unwrap();
253
254 fs::write(temp_dir.path().join("readme.md"), "# README").unwrap();
256 fs::write(temp_dir.path().join("spec.md"), "# Spec").unwrap();
257 fs::write(temp_dir.path().join("config.json"), "{}").unwrap();
258 fs::write(temp_dir.path().join("script.sh"), "#!/bin/bash").unwrap();
259
260 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
261
262 assert_eq!(snapshot.len(), 2, "Should only capture .md files");
263 }
264
265 #[test]
266 fn test_capture_stores_modification_time() {
267 let temp_dir = TempDir::new().unwrap();
268 let md_file = temp_dir.path().join("test.md");
269 fs::write(&md_file, "# Test").unwrap();
270
271 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
272
273 assert_eq!(snapshot.len(), 1);
274
275 let canonical = md_file.canonicalize().unwrap();
277 let metadata = snapshot.get(&canonical);
278 assert!(metadata.is_some(), "Should have metadata for the file");
279
280 let file_metadata = fs::metadata(&md_file).unwrap();
282 let actual_modified = file_metadata.modified().unwrap();
283 assert_eq!(
284 metadata.unwrap().modified,
285 actual_modified,
286 "Stored modification time should match file's actual modification time"
287 );
288 }
289
290 #[test]
291 fn test_contains_and_get() {
292 let temp_dir = TempDir::new().unwrap();
293 let md_file = temp_dir.path().join("test.md");
294 fs::write(&md_file, "# Test").unwrap();
295
296 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
297
298 let canonical = md_file.canonicalize().unwrap();
299 assert!(snapshot.contains(&canonical));
300 assert!(snapshot.get(&canonical).is_some());
301
302 let nonexistent = PathBuf::from("/nonexistent/file.md");
303 assert!(!snapshot.contains(&nonexistent));
304 assert!(snapshot.get(&nonexistent).is_none());
305 }
306
307 #[test]
308 fn test_capture_multiple_directories() {
309 let temp_dir1 = TempDir::new().unwrap();
310 let temp_dir2 = TempDir::new().unwrap();
311
312 fs::write(temp_dir1.path().join("file1.md"), "# File 1").unwrap();
313 fs::write(temp_dir2.path().join("file2.md"), "# File 2").unwrap();
314
315 let snapshot = SpecSnapshot::capture_from_dirs(&[
316 temp_dir1.path().to_path_buf(),
317 temp_dir2.path().to_path_buf(),
318 ]);
319
320 assert_eq!(
321 snapshot.len(),
322 2,
323 "Should capture files from both directories"
324 );
325 }
326
327 #[test]
328 fn test_capture_ignores_subdirectories() {
329 let temp_dir = TempDir::new().unwrap();
330
331 fs::write(temp_dir.path().join("main.md"), "# Main").unwrap();
333
334 let subdir = temp_dir.path().join("subdir");
336 fs::create_dir(&subdir).unwrap();
337 fs::write(subdir.join("nested.md"), "# Nested").unwrap();
338
339 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
340
341 assert_eq!(
342 snapshot.len(),
343 1,
344 "Should only capture files in the directory, not subdirectories"
345 );
346 }
347
348 #[test]
349 fn test_snapshot_timestamp_is_current() {
350 let before = SystemTime::now();
351 thread::sleep(Duration::from_millis(10));
352 let snapshot = SpecSnapshot::empty();
353 thread::sleep(Duration::from_millis(10));
354 let after = SystemTime::now();
355
356 assert!(
357 snapshot.timestamp >= before,
358 "Snapshot timestamp should be after 'before' time"
359 );
360 assert!(
361 snapshot.timestamp <= after,
362 "Snapshot timestamp should be before 'after' time"
363 );
364 }
365
366 #[test]
367 fn test_file_metadata_equality() {
368 let time = SystemTime::now();
369 let meta1 = FileMetadata { modified: time };
370 let meta2 = FileMetadata { modified: time };
371 let meta3 = FileMetadata {
372 modified: time + Duration::from_secs(1),
373 };
374
375 assert_eq!(meta1, meta2);
376 assert_ne!(meta1, meta3);
377 }
378
379 #[test]
380 fn test_capture_handles_mixed_directory_states() {
381 let temp_dir = TempDir::new().unwrap();
382 let existing_dir = temp_dir.path().to_path_buf();
383 let nonexistent_dir = PathBuf::from("/this/does/not/exist");
384
385 fs::write(existing_dir.join("test.md"), "# Test").unwrap();
386
387 let snapshot = SpecSnapshot::capture_from_dirs(&[existing_dir, nonexistent_dir]);
388
389 assert_eq!(
390 snapshot.len(),
391 1,
392 "Should capture from existing dir, ignore nonexistent"
393 );
394 }
395
396 #[test]
397 fn test_capture_deduplicates_same_file_via_symlink() {
398 let temp_dir = TempDir::new().unwrap();
399 let md_file = temp_dir.path().join("original.md");
400 fs::write(&md_file, "# Original").unwrap();
401
402 #[cfg(unix)]
404 {
405 let symlink = temp_dir.path().join("link.md");
406 std::os::unix::fs::symlink(&md_file, &symlink).unwrap();
407
408 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
409
410 assert!(snapshot.len() >= 1);
414 }
415 }
416
417 #[test]
420 fn test_detect_new_files_empty_snapshot_empty_dir() {
421 let temp_dir = TempDir::new().unwrap();
422 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
423
424 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
426 assert!(
427 new_files.is_empty(),
428 "Should detect no new files in unchanged directory"
429 );
430 }
431
432 #[test]
433 fn test_detect_new_files_detects_newly_created_file() {
434 let temp_dir = TempDir::new().unwrap();
435
436 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
438 assert!(snapshot.is_empty());
439
440 thread::sleep(Duration::from_millis(50));
442
443 let new_file = temp_dir.path().join("spec-new-feature.md");
445 fs::write(&new_file, "# New Spec").unwrap();
446
447 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
449
450 assert_eq!(new_files.len(), 1, "Should detect exactly one new file");
451 assert_eq!(new_files[0], new_file.canonicalize().unwrap());
452 }
453
454 #[test]
455 fn test_detect_new_files_detects_multiple_new_files() {
456 let temp_dir = TempDir::new().unwrap();
457
458 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
460
461 thread::sleep(Duration::from_millis(50));
463
464 fs::write(temp_dir.path().join("spec-one.md"), "# Spec 1").unwrap();
466 fs::write(temp_dir.path().join("spec-two.md"), "# Spec 2").unwrap();
467 fs::write(temp_dir.path().join("spec-three.md"), "# Spec 3").unwrap();
468
469 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
470
471 assert_eq!(new_files.len(), 3, "Should detect all three new files");
472 }
473
474 #[test]
475 fn test_detect_new_files_ignores_unchanged_files() {
476 let temp_dir = TempDir::new().unwrap();
477
478 let existing_file = temp_dir.path().join("existing.md");
480 fs::write(&existing_file, "# Existing Spec").unwrap();
481
482 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
484 assert_eq!(snapshot.len(), 1);
485
486 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
488
489 assert!(
490 new_files.is_empty(),
491 "Should not detect unchanged files as new"
492 );
493 }
494
495 #[test]
496 fn test_detect_new_files_detects_modified_file() {
497 let temp_dir = TempDir::new().unwrap();
498
499 let existing_file = temp_dir.path().join("existing.md");
501 fs::write(&existing_file, "# Original content").unwrap();
502
503 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
505
506 thread::sleep(Duration::from_millis(50));
508
509 fs::write(&existing_file, "# Modified content - this is new!").unwrap();
511
512 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
513
514 assert_eq!(new_files.len(), 1, "Should detect modified file as new");
515 assert_eq!(new_files[0], existing_file.canonicalize().unwrap());
516 }
517
518 #[test]
519 fn test_detect_new_files_only_detects_md_files() {
520 let temp_dir = TempDir::new().unwrap();
521
522 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
524
525 thread::sleep(Duration::from_millis(50));
526
527 fs::write(temp_dir.path().join("spec-feature.md"), "# Spec").unwrap();
529 fs::write(temp_dir.path().join("config.json"), "{}").unwrap();
530 fs::write(temp_dir.path().join("readme.txt"), "readme").unwrap();
531 fs::write(temp_dir.path().join("script.sh"), "#!/bin/bash").unwrap();
532
533 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
534
535 assert_eq!(new_files.len(), 1, "Should only detect .md file");
536 }
537
538 #[test]
539 fn test_detect_new_files_across_multiple_directories() {
540 let temp_dir1 = TempDir::new().unwrap();
541 let temp_dir2 = TempDir::new().unwrap();
542
543 let snapshot = SpecSnapshot::capture_from_dirs(&[
545 temp_dir1.path().to_path_buf(),
546 temp_dir2.path().to_path_buf(),
547 ]);
548
549 thread::sleep(Duration::from_millis(50));
550
551 fs::write(temp_dir1.path().join("spec1.md"), "# Spec 1").unwrap();
553 fs::write(temp_dir2.path().join("spec2.md"), "# Spec 2").unwrap();
554
555 let new_files = snapshot.detect_new_files_from_dirs(&[
556 temp_dir1.path().to_path_buf(),
557 temp_dir2.path().to_path_buf(),
558 ]);
559
560 assert_eq!(
561 new_files.len(),
562 2,
563 "Should detect new files from both directories"
564 );
565 }
566
567 #[test]
568 fn test_detect_new_files_returns_sorted_paths() {
569 let temp_dir = TempDir::new().unwrap();
570
571 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
573
574 thread::sleep(Duration::from_millis(50));
575
576 fs::write(temp_dir.path().join("zebra.md"), "# Z").unwrap();
578 fs::write(temp_dir.path().join("apple.md"), "# A").unwrap();
579 fs::write(temp_dir.path().join("mango.md"), "# M").unwrap();
580
581 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
582
583 assert_eq!(new_files.len(), 3);
584
585 let filenames: Vec<&str> = new_files
587 .iter()
588 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
589 .collect();
590 assert_eq!(filenames, vec!["apple.md", "mango.md", "zebra.md"]);
591 }
592
593 #[test]
594 fn test_detect_new_files_handles_nonexistent_directory() {
595 let temp_dir = TempDir::new().unwrap();
596 let nonexistent = PathBuf::from("/this/path/does/not/exist/at/all");
597
598 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
600
601 thread::sleep(Duration::from_millis(50));
602
603 fs::write(temp_dir.path().join("new.md"), "# New").unwrap();
604
605 let new_files =
607 snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf(), nonexistent]);
608
609 assert_eq!(
610 new_files.len(),
611 1,
612 "Should detect file from existing directory"
613 );
614 }
615
616 #[test]
617 fn test_detect_new_files_mixed_new_and_existing() {
618 let temp_dir = TempDir::new().unwrap();
619
620 fs::write(temp_dir.path().join("old1.md"), "# Old 1").unwrap();
622 fs::write(temp_dir.path().join("old2.md"), "# Old 2").unwrap();
623
624 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
626 assert_eq!(snapshot.len(), 2);
627
628 thread::sleep(Duration::from_millis(50));
629
630 fs::write(temp_dir.path().join("new1.md"), "# New 1").unwrap();
632 fs::write(temp_dir.path().join("new2.md"), "# New 2").unwrap();
633
634 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
635
636 assert_eq!(
637 new_files.len(),
638 2,
639 "Should only detect the 2 new files, not the 2 old ones"
640 );
641
642 let filenames: Vec<&str> = new_files
643 .iter()
644 .filter_map(|p| p.file_name().and_then(|n| n.to_str()))
645 .collect();
646 assert!(filenames.contains(&"new1.md"));
647 assert!(filenames.contains(&"new2.md"));
648 assert!(!filenames.contains(&"old1.md"));
649 assert!(!filenames.contains(&"old2.md"));
650 }
651
652 #[test]
653 fn test_detect_new_files_deleted_file_not_detected() {
654 let temp_dir = TempDir::new().unwrap();
655
656 let to_delete = temp_dir.path().join("delete_me.md");
658 fs::write(&to_delete, "# Delete me").unwrap();
659
660 let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
662 assert_eq!(snapshot.len(), 1);
663
664 fs::remove_file(&to_delete).unwrap();
666
667 let new_files = snapshot.detect_new_files_from_dirs(&[temp_dir.path().to_path_buf()]);
668
669 assert!(
670 new_files.is_empty(),
671 "Deleted files should not appear in new files list"
672 );
673 }
674}