1use std::collections::HashMap;
2use std::io;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6#[derive(Debug, Clone)]
14pub struct DocumentEntry {
15 pub version: i32,
17 pub mtime: Option<SystemTime>,
22 pub size: Option<u64>,
25}
26
27#[derive(Debug, Default)]
38pub struct DocumentStore {
39 entries: HashMap<PathBuf, DocumentEntry>,
40}
41
42impl DocumentStore {
43 pub fn new() -> Self {
44 Self {
45 entries: HashMap::new(),
46 }
47 }
48
49 pub fn is_open(&self, path: &Path) -> bool {
51 self.entries.contains_key(path)
52 }
53
54 pub fn open(&mut self, path: PathBuf) -> i32 {
60 let (mtime, size) = read_metadata(&path);
61 let entry = DocumentEntry {
62 version: 0,
63 mtime,
64 size,
65 };
66 self.entries.insert(path, entry);
67 0
68 }
69
70 pub fn bump_version(&mut self, path: &Path) -> Option<i32> {
75 let (new_mtime, new_size) = read_metadata(path);
76 let entry = self.entries.get_mut(path)?;
77 entry.version += 1;
78 entry.mtime = new_mtime;
79 entry.size = new_size;
80 Some(entry.version)
81 }
82
83 pub fn version(&self, path: &Path) -> Option<i32> {
85 self.entries.get(path).map(|e| e.version)
86 }
87
88 pub fn entry(&self, path: &Path) -> Option<&DocumentEntry> {
90 self.entries.get(path)
91 }
92
93 pub fn close(&mut self, path: &Path) -> Option<i32> {
96 self.entries.remove(path).map(|e| e.version)
97 }
98
99 pub fn open_documents(&self) -> Vec<&PathBuf> {
101 self.entries.keys().collect()
102 }
103
104 pub fn is_stale_on_disk(&self, path: &Path) -> bool {
113 let Some(entry) = self.entries.get(path) else {
114 return true;
118 };
119 let (current_mtime, current_size) = read_metadata(path);
120
121 match (entry.mtime, current_mtime) {
122 (Some(prev), Some(now)) if prev == now => {
123 entry.size != current_size
127 }
128 (Some(_), Some(_)) => true, _ => true,
131 }
132 }
133}
134
135fn read_metadata(path: &Path) -> (Option<SystemTime>, Option<u64>) {
139 match std::fs::metadata(path) {
140 Ok(meta) => {
141 let mtime = meta.modified().ok();
142 let size = Some(meta.len());
143 (mtime, size)
144 }
145 Err(_) => (None, None),
146 }
147}
148
149pub fn file_metadata(path: &Path) -> io::Result<(SystemTime, u64)> {
152 let meta = std::fs::metadata(path)?;
153 let mtime = meta.modified()?;
154 let size = meta.len();
155 Ok((mtime, size))
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161 use std::fs;
162 use std::io::Write;
163 use std::thread;
164 use std::time::Duration;
165
166 fn write_file(path: &Path, content: &str) {
167 let mut f = fs::File::create(path).expect("create test file");
168 f.write_all(content.as_bytes()).expect("write content");
169 }
170
171 #[test]
172 fn open_and_close_roundtrip() {
173 let mut store = DocumentStore::new();
174 let path = PathBuf::from("/tmp/aft-doc-test-doesnt-exist");
175 assert!(!store.is_open(&path));
176
177 let v = store.open(path.clone());
178 assert_eq!(v, 0);
179 assert!(store.is_open(&path));
180 assert_eq!(store.version(&path), Some(0));
181
182 let bumped = store.bump_version(&path);
183 assert_eq!(bumped, Some(1));
184 assert_eq!(store.version(&path), Some(1));
185
186 let closed = store.close(&path);
187 assert_eq!(closed, Some(1));
188 assert!(!store.is_open(&path));
189 }
190
191 #[test]
192 fn nonexistent_path_is_always_stale() {
193 let store = DocumentStore::new();
194 let path = PathBuf::from("/tmp/aft-doc-test-never-opened");
195 assert!(store.is_stale_on_disk(&path));
197 }
198
199 #[test]
200 fn freshly_opened_real_file_is_not_stale() {
201 let dir = tempfile::tempdir().expect("temp dir");
202 let path = dir.path().join("a.txt");
203 write_file(&path, "hello");
204
205 let mut store = DocumentStore::new();
206 store.open(path.clone());
207 assert!(!store.is_stale_on_disk(&path));
208 }
209
210 #[test]
211 fn opened_then_disk_changed_is_stale() {
212 let dir = tempfile::tempdir().expect("temp dir");
213 let path = dir.path().join("b.txt");
214 write_file(&path, "hello");
215
216 let mut store = DocumentStore::new();
217 store.open(path.clone());
218 assert!(!store.is_stale_on_disk(&path));
219
220 thread::sleep(Duration::from_millis(20));
223 write_file(&path, "hello world!");
224
225 assert!(store.is_stale_on_disk(&path));
226 }
227
228 #[test]
229 fn opened_file_then_deleted_is_stale() {
230 let dir = tempfile::tempdir().expect("temp dir");
231 let path = dir.path().join("c.txt");
232 write_file(&path, "data");
233
234 let mut store = DocumentStore::new();
235 store.open(path.clone());
236 assert!(!store.is_stale_on_disk(&path));
237
238 fs::remove_file(&path).expect("remove file");
239 assert!(store.is_stale_on_disk(&path));
241 }
242
243 #[test]
244 fn bump_version_refreshes_mtime() {
245 let dir = tempfile::tempdir().expect("temp dir");
246 let path = dir.path().join("d.txt");
247 write_file(&path, "original");
248
249 let mut store = DocumentStore::new();
250 store.open(path.clone());
251
252 thread::sleep(Duration::from_millis(20));
253 write_file(&path, "updated");
254 assert!(store.is_stale_on_disk(&path));
255
256 store.bump_version(&path);
259 assert!(!store.is_stale_on_disk(&path));
260 }
261
262 #[test]
263 fn open_documents_returns_all_paths() {
264 let mut store = DocumentStore::new();
265 store.open(PathBuf::from("/tmp/p1"));
266 store.open(PathBuf::from("/tmp/p2"));
267 let docs = store.open_documents();
268 assert_eq!(docs.len(), 2);
269 }
270
271 #[test]
272 fn entry_returns_full_state() {
273 let dir = tempfile::tempdir().expect("temp dir");
274 let path = dir.path().join("e.txt");
275 write_file(&path, "abc");
276
277 let mut store = DocumentStore::new();
278 store.open(path.clone());
279
280 let entry = store.entry(&path).expect("entry");
281 assert_eq!(entry.version, 0);
282 assert!(entry.mtime.is_some());
283 assert_eq!(entry.size, Some(3));
284 }
285}