Skip to main content

autom8/
snapshot.rs

1//! Spec file snapshot functionality for detecting new files after Claude sessions.
2//!
3//! This module provides utilities to snapshot the state of spec files (`.md` files)
4//! before spawning a Claude session, so that new files can be detected afterward.
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::PathBuf;
9use std::time::SystemTime;
10
11use crate::error::Result;
12
13/// Metadata for a single file in the snapshot.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FileMetadata {
16    /// The file's modification time.
17    pub modified: SystemTime,
18}
19
20/// A snapshot of spec files at a point in time.
21///
22/// This struct captures the state of `.md` files in relevant directories
23/// before spawning a Claude session, allowing detection of new files afterward.
24#[derive(Debug, Clone)]
25pub struct SpecSnapshot {
26    /// The timestamp when this snapshot was taken.
27    pub timestamp: SystemTime,
28    /// Map of file paths to their metadata at snapshot time.
29    pub files: HashMap<PathBuf, FileMetadata>,
30}
31
32impl SpecSnapshot {
33    /// Create a new snapshot by scanning the specified directories for `.md` files.
34    ///
35    /// Scans both:
36    /// - `~/.config/autom8/<project>/pdr/` (config directory)
37    /// - Current working directory
38    ///
39    /// Directories that don't exist are silently skipped (resulting in an empty
40    /// contribution to the snapshot from that location).
41    pub fn capture() -> Result<Self> {
42        let timestamp = SystemTime::now();
43        let mut files = HashMap::new();
44
45        // Scan config directory spec/
46        if let Ok(spec_dir) = crate::config::spec_dir() {
47            collect_md_files(&spec_dir, &mut files);
48        }
49
50        // Scan current working directory
51        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    /// Create a snapshot from specific directories (for testing).
59    #[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    /// Create an empty snapshot with the current timestamp (for testing).
72    #[cfg(test)]
73    pub fn empty() -> Self {
74        Self {
75            timestamp: SystemTime::now(),
76            files: HashMap::new(),
77        }
78    }
79
80    /// Returns the number of files in this snapshot.
81    pub fn len(&self) -> usize {
82        self.files.len()
83    }
84
85    /// Returns true if the snapshot contains no files.
86    pub fn is_empty(&self) -> bool {
87        self.files.is_empty()
88    }
89
90    /// Check if a file path exists in this snapshot.
91    pub fn contains(&self, path: &PathBuf) -> bool {
92        self.files.contains_key(path)
93    }
94
95    /// Get the metadata for a file path, if it exists in the snapshot.
96    pub fn get(&self, path: &PathBuf) -> Option<&FileMetadata> {
97        self.files.get(path)
98    }
99
100    /// Detect new spec files by comparing current state against this snapshot.
101    ///
102    /// Scans the same directories (config spec/ and current working directory) and returns
103    /// paths to files that are either:
104    /// - Not present in the original snapshot (newly created)
105    /// - Present but modified after the snapshot timestamp (modified during session)
106    ///
107    /// Returns a sorted list of canonical paths to new/modified `.md` files.
108    pub fn detect_new_files(&self) -> Result<Vec<PathBuf>> {
109        let mut current_files = HashMap::new();
110
111        // Scan config directory spec/
112        if let Ok(spec_dir) = crate::config::spec_dir() {
113            collect_md_files(&spec_dir, &mut current_files);
114        }
115
116        // Scan current working directory
117        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                // File wasn't in snapshot - it's new
126                None => {
127                    new_files.push(path);
128                }
129                // File was in snapshot - check if modified after snapshot timestamp
130                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        // Sort for deterministic output
141        new_files.sort();
142
143        Ok(new_files)
144    }
145
146    /// Detect new files from specific directories (for testing).
147    #[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
177/// Collect all `.md` files from a directory into the files map.
178///
179/// Non-existent directories are silently ignored.
180/// Only collects files directly in the directory (non-recursive).
181fn 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        // Only collect .md files
195        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        // Get modification time
204        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        // Canonicalize path to avoid duplicates from different paths to same file
214        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        // Create various files
255        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        // Get the canonical path to look up in snapshot
276        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        // Verify modification time is reasonable (not in the future, not too old)
281        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        // Create file in main directory
332        fs::write(temp_dir.path().join("main.md"), "# Main").unwrap();
333
334        // Create subdirectory with file
335        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        // Create a symlink to the same file (Unix only)
403        #[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            // Due to canonicalization, the symlink should resolve to the same file
411            // but since we're iterating directory entries, we might see both
412            // The key point is canonical paths should be used
413            assert!(snapshot.len() >= 1);
414        }
415    }
416
417    // ===== Detection Logic Tests =====
418
419    #[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        // No changes - should detect nothing
425        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        // Take snapshot of empty directory
437        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
438        assert!(snapshot.is_empty());
439
440        // Small delay to ensure file timestamp is after snapshot
441        thread::sleep(Duration::from_millis(50));
442
443        // Create new file
444        let new_file = temp_dir.path().join("spec-new-feature.md");
445        fs::write(&new_file, "# New Spec").unwrap();
446
447        // Detect new files
448        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        // Take snapshot of empty directory
459        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
460
461        // Small delay
462        thread::sleep(Duration::from_millis(50));
463
464        // Create multiple new files
465        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        // Create existing file
479        let existing_file = temp_dir.path().join("existing.md");
480        fs::write(&existing_file, "# Existing Spec").unwrap();
481
482        // Take snapshot
483        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
484        assert_eq!(snapshot.len(), 1);
485
486        // Detect without any changes
487        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        // Create existing file
500        let existing_file = temp_dir.path().join("existing.md");
501        fs::write(&existing_file, "# Original content").unwrap();
502
503        // Take snapshot
504        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
505
506        // Small delay to ensure modification time differs
507        thread::sleep(Duration::from_millis(50));
508
509        // Modify the file
510        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        // Take snapshot of empty directory
523        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
524
525        thread::sleep(Duration::from_millis(50));
526
527        // Create files of various types
528        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        // Take snapshot of both directories
544        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        // Create new files in both directories
552        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        // Take snapshot of empty directory
572        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
573
574        thread::sleep(Duration::from_millis(50));
575
576        // Create files in non-alphabetical order
577        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        // Verify sorted order
586        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        // Snapshot from temp_dir, then detect including nonexistent dir
599        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        // Should still work, just ignore the nonexistent directory
606        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        // Create some existing files
621        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        // Take snapshot
625        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        // Create some new files (leave old files unchanged)
631        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        // Create a file
657        let to_delete = temp_dir.path().join("delete_me.md");
658        fs::write(&to_delete, "# Delete me").unwrap();
659
660        // Take snapshot
661        let snapshot = SpecSnapshot::capture_from_dirs(&[temp_dir.path().to_path_buf()]);
662        assert_eq!(snapshot.len(), 1);
663
664        // Delete the file
665        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}