Skip to main content

atomcode_core/tool/
file_history.rs

1//! File-level snapshot history — backup files before every edit/write.
2//!
3//! Inspired by Claude Code's file checkpointing: every file is backed up
4//! before modification, stored in `$ATOMCODE_HOME/file-history/{session}/`.
5//! No git required. Users can rewind to any previous version via `/undo`.
6//!
7//! Design:
8//! - `backup_before_write(path)` → copies the file to backup dir (no-op if new file)
9//! - Backup filename: `{sha256_of_path_first16}@v{version}`
10//! - Max 50 versions per file per session
11//! - `restore(path, version)` → copies backup back to original location
12//! - `list_versions(path)` → returns available versions with timestamps
13
14use std::collections::HashMap;
15use std::hash::{Hash, Hasher};
16use std::path::{Path, PathBuf};
17
18/// Per-file version tracker.
19struct FileVersions {
20    /// Next version number to assign.
21    next_version: u32,
22    /// version → backup filename
23    backups: Vec<(u32, String, std::time::SystemTime)>,
24}
25
26/// File history manager for one session.
27pub struct FileHistory {
28    /// Base directory: $ATOMCODE_HOME/file-history/{session}/
29    backup_dir: PathBuf,
30    /// Track versions per file path.
31    files: HashMap<String, FileVersions>,
32}
33
34const MAX_VERSIONS_PER_FILE: usize = 50;
35
36impl FileHistory {
37    pub fn new(session_id: &str) -> Self {
38        let config_dir = crate::config::Config::config_dir();
39        let backup_dir = config_dir.join("file-history").join(session_id);
40        Self {
41            backup_dir,
42            files: HashMap::new(),
43        }
44    }
45
46    /// Backup a file before it gets modified.
47    /// Call this BEFORE writing/editing. No-op if file doesn't exist (new file).
48    /// Returns the backup version number, or None if no backup was needed.
49    pub async fn backup_before_write(&mut self, file_path: &str) -> Option<u32> {
50        let path = Path::new(file_path);
51        if !path.exists() {
52            return None; // New file, nothing to backup
53        }
54
55        // Ensure backup directory exists
56        if let Err(e) = tokio::fs::create_dir_all(&self.backup_dir).await {
57            eprintln!("[file-history] Failed to create backup dir: {}", e);
58            return None;
59        }
60
61        let versions = self
62            .files
63            .entry(file_path.to_string())
64            .or_insert_with(|| FileVersions {
65                next_version: 1,
66                backups: Vec::new(),
67            });
68
69        let version = versions.next_version;
70        let backup_name = backup_filename(file_path, version);
71        let backup_path = self.backup_dir.join(&backup_name);
72
73        // Copy file to backup (system-level copy, no memory overhead)
74        if let Err(e) = tokio::fs::copy(file_path, &backup_path).await {
75            eprintln!("[file-history] Failed to backup {}: {}", file_path, e);
76            return None;
77        }
78
79        versions
80            .backups
81            .push((version, backup_name, std::time::SystemTime::now()));
82        versions.next_version += 1;
83
84        // Evict old versions if over limit
85        while versions.backups.len() > MAX_VERSIONS_PER_FILE {
86            if let Some((_, old_name, _)) = versions.backups.first() {
87                let old_path = self.backup_dir.join(old_name);
88                let _ = tokio::fs::remove_file(&old_path).await;
89            }
90            versions.backups.remove(0);
91        }
92
93        Some(version)
94    }
95
96    /// Restore a file to a specific version.
97    /// Returns Ok(version) on success, Err(message) on failure.
98    pub async fn restore(&self, file_path: &str, version: Option<u32>) -> Result<u32, String> {
99        let versions = self
100            .files
101            .get(file_path)
102            .ok_or_else(|| format!("No history for {}", file_path))?;
103
104        let (ver, backup_name, _) = if let Some(v) = version {
105            versions
106                .backups
107                .iter()
108                .find(|(bv, _, _)| *bv == v)
109                .ok_or_else(|| format!("Version {} not found for {}", v, file_path))?
110        } else {
111            // Latest version before current
112            versions
113                .backups
114                .last()
115                .ok_or_else(|| format!("No backups for {}", file_path))?
116        };
117
118        let backup_path = self.backup_dir.join(backup_name);
119        tokio::fs::copy(&backup_path, file_path)
120            .await
121            .map_err(|e| format!("Failed to restore {}: {}", file_path, e))?;
122
123        Ok(*ver)
124    }
125
126    /// List all backed-up files and their version counts.
127    pub fn list_files(&self) -> Vec<(String, usize)> {
128        self.files
129            .iter()
130            .map(|(path, v)| (path.clone(), v.backups.len()))
131            .collect()
132    }
133
134    /// Get the most recently backed-up version number for a file.
135    pub fn latest_version(&self, file_path: &str) -> Option<u32> {
136        self.files
137            .get(file_path)
138            .and_then(|v| v.backups.last())
139            .map(|(ver, _, _)| *ver)
140    }
141
142    /// Clean up all backups for this session.
143    pub async fn cleanup(&self) {
144        let _ = tokio::fs::remove_dir_all(&self.backup_dir).await;
145    }
146}
147
148/// Generate a deterministic backup filename from file path + version.
149fn backup_filename(file_path: &str, version: u32) -> String {
150    let mut hasher = std::collections::hash_map::DefaultHasher::new();
151    file_path.hash(&mut hasher);
152    let hash = hasher.finish();
153    format!("{:016x}@v{}", hash, version)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[tokio::test]
161    async fn test_backup_and_restore() {
162        let dir = std::env::temp_dir().join("atomcode_test_fh_backup");
163        let _ = std::fs::create_dir_all(&dir);
164        let test_file = dir.join("test.txt");
165        std::fs::write(&test_file, "version 1 content").unwrap();
166
167        let mut fh = FileHistory::new("test-session-1");
168        let file_str = test_file.to_string_lossy().to_string();
169
170        // Backup before first edit
171        let v = fh.backup_before_write(&file_str).await;
172        assert_eq!(v, Some(1));
173
174        // Simulate edit
175        std::fs::write(&test_file, "version 2 content").unwrap();
176
177        // Backup before second edit
178        let v = fh.backup_before_write(&file_str).await;
179        assert_eq!(v, Some(2));
180
181        // Simulate another edit
182        std::fs::write(&test_file, "version 3 BROKEN").unwrap();
183
184        // Restore to version 1
185        let restored = fh.restore(&file_str, Some(1)).await.unwrap();
186        assert_eq!(restored, 1);
187        assert_eq!(
188            std::fs::read_to_string(&test_file).unwrap(),
189            "version 1 content"
190        );
191
192        // Restore to latest (version 2)
193        std::fs::write(&test_file, "broken again").unwrap();
194        let restored = fh.restore(&file_str, None).await.unwrap();
195        assert_eq!(restored, 2);
196        assert_eq!(
197            std::fs::read_to_string(&test_file).unwrap(),
198            "version 2 content"
199        );
200
201        // Cleanup
202        fh.cleanup().await;
203        let _ = std::fs::remove_dir_all(&dir);
204    }
205
206    #[tokio::test]
207    async fn test_new_file_no_backup() {
208        let mut fh = FileHistory::new("test-session-2");
209        let v = fh.backup_before_write("/nonexistent/file.txt").await;
210        assert_eq!(v, None);
211        fh.cleanup().await;
212    }
213
214    #[tokio::test]
215    async fn test_eviction() {
216        let dir = std::env::temp_dir().join("atomcode_test_fh_evict");
217        let _ = std::fs::create_dir_all(&dir);
218        let test_file = dir.join("evict.txt");
219        std::fs::write(&test_file, "content").unwrap();
220
221        let mut fh = FileHistory::new("test-session-3");
222        let file_str = test_file.to_string_lossy().to_string();
223
224        // Create MAX + 5 versions
225        for _ in 0..(MAX_VERSIONS_PER_FILE + 5) {
226            fh.backup_before_write(&file_str).await;
227        }
228
229        let versions = &fh.files[&file_str];
230        assert_eq!(versions.backups.len(), MAX_VERSIONS_PER_FILE);
231
232        fh.cleanup().await;
233        let _ = std::fs::remove_dir_all(&dir);
234    }
235
236    #[test]
237    fn test_backup_filename_deterministic() {
238        let a = backup_filename("/path/to/file.ts", 1);
239        let b = backup_filename("/path/to/file.ts", 1);
240        assert_eq!(a, b);
241
242        let c = backup_filename("/path/to/file.ts", 2);
243        assert_ne!(a, c); // Different version
244    }
245}