1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6const CONTENT_HASH_SIZE_LIMIT_BYTES: u64 = 4 * 1024 * 1024;
7
8#[derive(Debug, Clone)]
17pub struct DocumentEntry {
18 pub version: i32,
20 pub mtime: Option<SystemTime>,
25 pub size: Option<u64>,
28 pub content_hash: Option<[u8; 32]>,
32}
33
34#[derive(Debug, Default)]
45pub struct DocumentStore {
46 entries: HashMap<PathBuf, DocumentEntry>,
47}
48
49impl DocumentStore {
50 pub fn new() -> Self {
51 Self {
52 entries: HashMap::new(),
53 }
54 }
55
56 pub fn is_open(&self, path: &Path) -> bool {
58 self.entries.contains_key(path)
59 }
60
61 pub fn open(&mut self, path: PathBuf) -> i32 {
67 let (mtime, size, content_hash) = read_metadata_and_hash(&path);
68 let entry = DocumentEntry {
69 version: 0,
70 mtime,
71 size,
72 content_hash,
73 };
74 self.entries.insert(path, entry);
75 0
76 }
77
78 pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
83 let (new_mtime, new_size, new_content_hash) = read_metadata_and_hash(path);
84 let entry = self.entries.get_mut(path)?;
85 entry.version += 1;
86 entry.mtime = new_mtime;
87 entry.size = new_size;
88 entry.content_hash = new_content_hash;
89 Some(entry.version)
90 }
91
92 pub fn version(&self, path: &Path) -> Option<i32> {
94 self.entries.get(path).map(|e| e.version)
95 }
96
97 pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
99 self.entries.get(path)
100 }
101
102 pub fn close(&mut self, path: &Path) -> Option<i32> {
105 self.entries.remove(path).map(|e| e.version)
106 }
107
108 pub fn open_documents(&self) -> Vec<&PathBuf> {
110 self.entries.keys().collect()
111 }
112
113 pub fn is_stale_on_disk(&self, path: &Path) -> bool {
122 let Some(entry) = self.entries.get(path) else {
123 return true;
127 };
128 let (current_mtime, current_size, current_content_hash) = read_metadata_and_hash(path);
129
130 match (entry.mtime, current_mtime) {
131 (Some(prev), Some(now)) if prev == now => {
132 if entry.size != current_size {
133 return true;
134 }
135
136 match current_size {
137 Some(size) if size > CONTENT_HASH_SIZE_LIMIT_BYTES => false,
138 Some(_) => match (entry.content_hash, current_content_hash) {
139 (Some(prev_hash), Some(now_hash)) => prev_hash != now_hash,
140 _ => true,
141 },
142 None => true,
143 }
144 }
145 (Some(_), Some(_)) => true, _ => true,
148 }
149 }
150}
151
152fn read_metadata_and_hash(path: &Path) -> (Option<SystemTime>, Option<u64>, Option<[u8; 32]>) {
157 match std::fs::metadata(path) {
158 Ok(meta) => {
159 let mtime = meta.modified().ok();
160 let size = Some(meta.len());
161 let content_hash = if meta.len() <= CONTENT_HASH_SIZE_LIMIT_BYTES {
162 std::fs::read(path)
163 .ok()
164 .map(|bytes| *blake3::hash(&bytes).as_bytes())
165 } else {
166 None
167 };
168 (mtime, size, content_hash)
169 }
170 Err(_) => (None, None, None),
171 }
172}
173
174pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
177 let meta = std::fs::metadata(path)?;
178 let mtime = meta.modified()?;
179 let size = meta.len();
180 Ok((mtime, size))
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186 use std::fs;
187 use std::io::Write;
188 use std::time::Duration;
189
190 fn write_file(path: &Path, content: &str) {
191 let mut f = fs::File::create(path).expect("create test file");
192 f.write_all(content.as_bytes()).expect("write content");
193 }
194
195 fn advance_mtime(path: &Path, duration: Duration) {
196 let modified = fs::metadata(path)
197 .expect("stat test file")
198 .modified()
199 .expect("test file mtime");
200 let advanced = modified.checked_add(duration).expect("advanced mtime");
201 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(advanced))
202 .expect("set advanced mtime");
203 }
204
205 #[test]
206 fn open_and_close_roundtrip() {
207 let mut store = DocumentStore::new();
208 let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
209 assert!(!store.is_open(&path));
210
211 let v = store.open(path.clone());
212 assert_eq!(v, 0);
213 assert!(store.is_open(&path));
214 assert_eq!(store.version(&path), Some(0));
215
216 let bumped = store.bump_version(&path);
217 assert_eq!(bumped, Some(1));
218 assert_eq!(store.version(&path), Some(1));
219
220 let closed = store.close(&path);
221 assert_eq!(closed, Some(1));
222 assert!(!store.is_open(&path));
223 }
224
225 #[test]
226 fn nonexistent_path_is_always_stale() {
227 let store = DocumentStore::new();
228 let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
229 assert!(store.is_stale_on_disk(&path));
231 }
232
233 #[test]
234 fn freshly_opened_real_file_is_not_stale() {
235 let dir = tempfile::tempdir().expect("temp dir");
236 let path = dir.path().join("a.txt");
237 write_file(&path, "hello");
238
239 let mut store = DocumentStore::new();
240 store.open(path.clone());
241 assert!(!store.is_stale_on_disk(&path));
242 }
243
244 #[test]
245 fn opened_then_disk_changed_is_stale() {
246 let dir = tempfile::tempdir().expect("temp dir");
247 let path = dir.path().join("b.txt");
248 write_file(&path, "hello");
249
250 let mut store = DocumentStore::new();
251 store.open(path.clone());
252 assert!(!store.is_stale_on_disk(&path));
253
254 write_file(&path, "hello world!");
255 advance_mtime(&path, Duration::from_secs(2));
256
257 assert!(store.is_stale_on_disk(&path));
258 }
259
260 #[test]
261 fn opened_file_same_size_and_mtime_but_different_hash_is_stale() {
262 let dir = tempfile::tempdir().expect("temp dir");
263 let path = dir.path().join("same-size.txt");
264 write_file(&path, "hello");
265 let original_mtime = fs::metadata(&path)
266 .expect("stat original")
267 .modified()
268 .expect("original mtime");
269
270 let mut store = DocumentStore::new();
271 store.open(path.clone());
272 assert!(!store.is_stale_on_disk(&path));
273
274 write_file(&path, "jello");
275 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(original_mtime))
276 .expect("restore original mtime");
277
278 assert!(
279 store.is_stale_on_disk(&path),
280 "same mtime+size should still be stale when content hash differs"
281 );
282
283 store.bump_version(&path);
284 assert!(!store.is_stale_on_disk(&path));
285 }
286
287 #[test]
288 fn opened_file_then_deleted_is_stale() {
289 let dir = tempfile::tempdir().expect("temp dir");
290 let path = dir.path().join("c.txt");
291 write_file(&path, "data");
292
293 let mut store = DocumentStore::new();
294 store.open(path.clone());
295 assert!(!store.is_stale_on_disk(&path));
296
297 fs::remove_file(&path).expect("remove file");
298 assert!(store.is_stale_on_disk(&path));
300 }
301
302 #[test]
303 fn bump_version_refreshes_mtime() {
304 let dir = tempfile::tempdir().expect("temp dir");
305 let path = dir.path().join("d.txt");
306 write_file(&path, "original");
307
308 let mut store = DocumentStore::new();
309 store.open(path.clone());
310
311 write_file(&path, "updated");
312 advance_mtime(&path, Duration::from_secs(2));
313 assert!(store.is_stale_on_disk(&path));
314
315 store.bump_version(&path);
318 assert!(!store.is_stale_on_disk(&path));
319 }
320
321 #[test]
322 fn open_documents_returns_all_paths() {
323 let mut store = DocumentStore::new();
324 store.open(PathBuf::from("/tmp/p1"));
325 store.open(PathBuf::from("/tmp/p2"));
326 let docs = store.open_documents();
327 assert_eq!(docs.len(), 2);
328 }
329
330 #[test]
331 fn entry_returns_full_state() {
332 let dir = tempfile::tempdir().expect("temp dir");
333 let path = dir.path().join("e.txt");
334 write_file(&path, "abc");
335
336 let mut store = DocumentStore::new();
337 store.open(path.clone());
338
339 let entry = store.entry(&path).expect("entry");
340 assert_eq!(entry.version, 0);
341 assert!(entry.mtime.is_some());
342 assert_eq!(entry.size, Some(3));
343 assert!(entry.content_hash.is_some());
344 }
345}