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 estimated_memory(&self) -> crate::memory::MemoryEstimate {
57 let bytes = self.entries.keys().fold(0u64, |bytes, path| {
58 bytes
59 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
60 .saturating_add(std::mem::size_of::<DocumentEntry>() as u64)
61 .saturating_add(crate::memory::path_bytes(path))
62 });
63 crate::memory::MemoryEstimate::estimated(bytes).count("documents", self.entries.len())
64 }
65
66 pub fn is_open(&self, path: &Path) -> bool {
68 self.entries.contains_key(path)
69 }
70
71 pub fn open(&mut self, path: PathBuf) -> i32 {
77 let (mtime, size, content_hash) = read_metadata_and_hash(&path);
78 let entry = DocumentEntry {
79 version: 0,
80 mtime,
81 size,
82 content_hash,
83 };
84 self.entries.insert(path, entry);
85 0
86 }
87
88 pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
93 let (new_mtime, new_size, new_content_hash) = read_metadata_and_hash(path);
94 let entry = self.entries.get_mut(path)?;
95 entry.version += 1;
96 entry.mtime = new_mtime;
97 entry.size = new_size;
98 entry.content_hash = new_content_hash;
99 Some(entry.version)
100 }
101
102 pub fn version(&self, path: &Path) -> Option<i32> {
104 self.entries.get(path).map(|e| e.version)
105 }
106
107 pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
109 self.entries.get(path)
110 }
111
112 pub fn close(&mut self, path: &Path) -> Option<i32> {
115 self.entries.remove(path).map(|e| e.version)
116 }
117
118 pub fn open_documents(&self) -> Vec<&PathBuf> {
120 self.entries.keys().collect()
121 }
122
123 pub fn is_stale_on_disk(&self, path: &Path) -> bool {
132 let Some(entry) = self.entries.get(path) else {
133 return true;
137 };
138 let (current_mtime, current_size, current_content_hash) = read_metadata_and_hash(path);
139
140 match (entry.mtime, current_mtime) {
141 (Some(prev), Some(now)) if prev == now => {
142 if entry.size != current_size {
143 return true;
144 }
145
146 match current_size {
147 Some(size) if size > CONTENT_HASH_SIZE_LIMIT_BYTES => false,
148 Some(_) => match (entry.content_hash, current_content_hash) {
149 (Some(prev_hash), Some(now_hash)) => prev_hash != now_hash,
150 _ => true,
151 },
152 None => true,
153 }
154 }
155 (Some(_), Some(_)) => true, _ => true,
158 }
159 }
160}
161
162fn read_metadata_and_hash(path: &Path) -> (Option<SystemTime>, Option<u64>, Option<[u8; 32]>) {
167 match std::fs::metadata(path) {
168 Ok(meta) => {
169 let mtime = meta.modified().ok();
170 let size = Some(meta.len());
171 let content_hash = if meta.len() <= CONTENT_HASH_SIZE_LIMIT_BYTES {
172 std::fs::read(path)
173 .ok()
174 .map(|bytes| *blake3::hash(&bytes).as_bytes())
175 } else {
176 None
177 };
178 (mtime, size, content_hash)
179 }
180 Err(_) => (None, None, None),
181 }
182}
183
184pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
187 let meta = std::fs::metadata(path)?;
188 let mtime = meta.modified()?;
189 let size = meta.len();
190 Ok((mtime, size))
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use std::fs;
197 use std::io::Write;
198 use std::time::Duration;
199
200 fn write_file(path: &Path, content: &str) {
201 let mut f = fs::File::create(path).expect("create test file");
202 f.write_all(content.as_bytes()).expect("write content");
203 }
204
205 fn advance_mtime(path: &Path, duration: Duration) {
206 let modified = fs::metadata(path)
207 .expect("stat test file")
208 .modified()
209 .expect("test file mtime");
210 let advanced = modified.checked_add(duration).expect("advanced mtime");
211 filetime::set_file_mtime(path, filetime::FileTime::from_system_time(advanced))
212 .expect("set advanced mtime");
213 }
214
215 #[test]
216 fn document_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
217 let mut store = DocumentStore::new();
218 assert_eq!(store.estimated_memory().estimated_bytes, Some(0));
219 store.open(PathBuf::from("/tmp/aft-document-memory"));
220 let estimate = store.estimated_memory();
221 assert!(estimate.estimated_bytes.unwrap() > 0);
222 assert_eq!(estimate.counts["documents"], 1);
223 }
224
225 #[test]
226 fn open_and_close_roundtrip() {
227 let mut store = DocumentStore::new();
228 let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
229 assert!(!store.is_open(&path));
230
231 let v = store.open(path.clone());
232 assert_eq!(v, 0);
233 assert!(store.is_open(&path));
234 assert_eq!(store.version(&path), Some(0));
235
236 let bumped = store.bump_version(&path);
237 assert_eq!(bumped, Some(1));
238 assert_eq!(store.version(&path), Some(1));
239
240 let closed = store.close(&path);
241 assert_eq!(closed, Some(1));
242 assert!(!store.is_open(&path));
243 }
244
245 #[test]
246 fn nonexistent_path_is_always_stale() {
247 let store = DocumentStore::new();
248 let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
249 assert!(store.is_stale_on_disk(&path));
251 }
252
253 #[test]
254 fn freshly_opened_real_file_is_not_stale() {
255 let dir = tempfile::tempdir().expect("temp dir");
256 let path = dir.path().join("a.txt");
257 write_file(&path, "hello");
258
259 let mut store = DocumentStore::new();
260 store.open(path.clone());
261 assert!(!store.is_stale_on_disk(&path));
262 }
263
264 #[test]
265 fn opened_then_disk_changed_is_stale() {
266 let dir = tempfile::tempdir().expect("temp dir");
267 let path = dir.path().join("b.txt");
268 write_file(&path, "hello");
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, "hello world!");
275 advance_mtime(&path, Duration::from_secs(2));
276
277 assert!(store.is_stale_on_disk(&path));
278 }
279
280 #[test]
281 fn opened_file_same_size_and_mtime_but_different_hash_is_stale() {
282 let dir = tempfile::tempdir().expect("temp dir");
283 let path = dir.path().join("same-size.txt");
284 write_file(&path, "hello");
285 let original_mtime = fs::metadata(&path)
286 .expect("stat original")
287 .modified()
288 .expect("original mtime");
289
290 let mut store = DocumentStore::new();
291 store.open(path.clone());
292 assert!(!store.is_stale_on_disk(&path));
293
294 write_file(&path, "jello");
295 filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(original_mtime))
296 .expect("restore original mtime");
297
298 assert!(
299 store.is_stale_on_disk(&path),
300 "same mtime+size should still be stale when content hash differs"
301 );
302
303 store.bump_version(&path);
304 assert!(!store.is_stale_on_disk(&path));
305 }
306
307 #[test]
308 fn opened_file_then_deleted_is_stale() {
309 let dir = tempfile::tempdir().expect("temp dir");
310 let path = dir.path().join("c.txt");
311 write_file(&path, "data");
312
313 let mut store = DocumentStore::new();
314 store.open(path.clone());
315 assert!(!store.is_stale_on_disk(&path));
316
317 fs::remove_file(&path).expect("remove file");
318 assert!(store.is_stale_on_disk(&path));
320 }
321
322 #[test]
323 fn bump_version_refreshes_mtime() {
324 let dir = tempfile::tempdir().expect("temp dir");
325 let path = dir.path().join("d.txt");
326 write_file(&path, "original");
327
328 let mut store = DocumentStore::new();
329 store.open(path.clone());
330
331 write_file(&path, "updated");
332 advance_mtime(&path, Duration::from_secs(2));
333 assert!(store.is_stale_on_disk(&path));
334
335 store.bump_version(&path);
338 assert!(!store.is_stale_on_disk(&path));
339 }
340
341 #[test]
342 fn open_documents_returns_all_paths() {
343 let mut store = DocumentStore::new();
344 store.open(PathBuf::from("/tmp/p1"));
345 store.open(PathBuf::from("/tmp/p2"));
346 let docs = store.open_documents();
347 assert_eq!(docs.len(), 2);
348 }
349
350 #[test]
351 fn entry_returns_full_state() {
352 let dir = tempfile::tempdir().expect("temp dir");
353 let path = dir.path().join("e.txt");
354 write_file(&path, "abc");
355
356 let mut store = DocumentStore::new();
357 store.open(path.clone());
358
359 let entry = store.entry(&path).expect("entry");
360 assert_eq!(entry.version, 0);
361 assert!(entry.mtime.is_some());
362 assert_eq!(entry.size, Some(3));
363 assert!(entry.content_hash.is_some());
364 }
365}