atomcode_core/tool/
file_history.rs1use std::collections::HashMap;
15use std::hash::{Hash, Hasher};
16use std::path::{Path, PathBuf};
17
18struct FileVersions {
20 next_version: u32,
22 backups: Vec<(u32, String, std::time::SystemTime)>,
24}
25
26pub struct FileHistory {
28 backup_dir: PathBuf,
30 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 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; }
54
55 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 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 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 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 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 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 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 pub async fn cleanup(&self) {
144 let _ = tokio::fs::remove_dir_all(&self.backup_dir).await;
145 }
146}
147
148fn 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 let v = fh.backup_before_write(&file_str).await;
172 assert_eq!(v, Some(1));
173
174 std::fs::write(&test_file, "version 2 content").unwrap();
176
177 let v = fh.backup_before_write(&file_str).await;
179 assert_eq!(v, Some(2));
180
181 std::fs::write(&test_file, "version 3 BROKEN").unwrap();
183
184 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 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 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 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); }
245}