heartbit_core/tool/builtins/
file_tracker.rs1#![allow(missing_docs)]
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use parking_lot::RwLock;
7
8pub struct FileTracker {
19 records: RwLock<HashMap<PathBuf, FileRecord>>,
20}
21
22struct FileRecord {
23 modified_at: Option<SystemTime>,
25}
26
27impl Default for FileTracker {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl FileTracker {
34 pub fn new() -> Self {
35 Self {
36 records: RwLock::new(HashMap::new()),
37 }
38 }
39
40 pub fn record_read(&self, path: &Path) -> std::io::Result<()> {
42 let modified_at = match std::fs::metadata(path) {
43 Ok(meta) => meta.modified().ok(),
44 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
45 Err(e) => return Err(e),
46 };
47 let canonical = std::fs::canonicalize(path)
48 .or_else(|_| std::path::absolute(path))
49 .unwrap_or_else(|_| path.to_path_buf());
50 self.records
51 .write()
52 .insert(canonical, FileRecord { modified_at });
53 Ok(())
54 }
55
56 pub fn check_unmodified(&self, path: &Path) -> Result<(), String> {
61 let canonical = std::fs::canonicalize(path)
62 .or_else(|_| std::path::absolute(path))
63 .unwrap_or_else(|_| path.to_path_buf());
64 let records = self.records.read();
65 let record = records.get(&canonical).ok_or_else(|| {
66 format!(
67 "File {} has not been read yet. Read it first before editing.",
68 path.display()
69 )
70 })?;
71
72 let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());
73
74 match (record.modified_at, current_mtime) {
75 (Some(recorded), Some(current)) if recorded == current => Ok(()),
76 (None, None) => Ok(()),
77 _ => Err(format!(
78 "File {} has been modified since it was last read. Read it again before editing.",
79 path.display()
80 )),
81 }
82 }
83
84 pub fn was_read(&self, path: &Path) -> bool {
86 let canonical = std::fs::canonicalize(path)
87 .or_else(|_| std::path::absolute(path))
88 .unwrap_or_else(|_| path.to_path_buf());
89 self.records.read().contains_key(&canonical)
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use std::io::Write;
97
98 #[test]
99 fn record_read_and_was_read() {
100 let dir = tempfile::tempdir().unwrap();
101 let path = dir.path().join("test.txt");
102 std::fs::write(&path, "hello").unwrap();
103
104 let tracker = FileTracker::new();
105 assert!(!tracker.was_read(&path));
106
107 tracker.record_read(&path).unwrap();
108 assert!(tracker.was_read(&path));
109 }
110
111 #[test]
112 fn check_unmodified_passes_when_unchanged() {
113 let dir = tempfile::tempdir().unwrap();
114 let path = dir.path().join("test.txt");
115 std::fs::write(&path, "hello").unwrap();
116
117 let tracker = FileTracker::new();
118 tracker.record_read(&path).unwrap();
119 assert!(tracker.check_unmodified(&path).is_ok());
120 }
121
122 #[test]
123 fn check_unmodified_fails_when_never_read() {
124 let dir = tempfile::tempdir().unwrap();
125 let path = dir.path().join("test.txt");
126 std::fs::write(&path, "hello").unwrap();
127
128 let tracker = FileTracker::new();
129 let err = tracker.check_unmodified(&path).unwrap_err();
130 assert!(err.contains("has not been read yet"), "got: {err}");
131 }
132
133 #[test]
134 fn check_unmodified_fails_when_modified_externally() {
135 let dir = tempfile::tempdir().unwrap();
136 let path = dir.path().join("test.txt");
137 std::fs::write(&path, "hello").unwrap();
138
139 let tracker = FileTracker::new();
140 tracker.record_read(&path).unwrap();
141
142 std::thread::sleep(std::time::Duration::from_millis(50));
144
145 let mut f = std::fs::OpenOptions::new()
147 .write(true)
148 .truncate(true)
149 .open(&path)
150 .unwrap();
151 f.write_all(b"modified").unwrap();
152 f.sync_all().unwrap();
153
154 let err = tracker.check_unmodified(&path).unwrap_err();
155 assert!(err.contains("has been modified"), "got: {err}");
156 }
157
158 #[test]
159 fn record_read_updates_mtime_after_write() {
160 let dir = tempfile::tempdir().unwrap();
161 let path = dir.path().join("test.txt");
162 std::fs::write(&path, "hello").unwrap();
163
164 let tracker = FileTracker::new();
165 tracker.record_read(&path).unwrap();
166
167 std::thread::sleep(std::time::Duration::from_millis(50));
169 std::fs::write(&path, "changed").unwrap();
170 tracker.record_read(&path).unwrap();
171
172 assert!(tracker.check_unmodified(&path).is_ok());
174 }
175
176 #[test]
177 fn record_read_nonexistent_file_ok() {
178 let tracker = FileTracker::new();
179 let path = Path::new("/tmp/nonexistent_heartbit_test_file_12345");
180 tracker.record_read(path).unwrap();
182 }
183
184 #[test]
185 fn check_unmodified_fails_when_file_deleted_after_read() {
186 let dir = tempfile::tempdir().unwrap();
187 let path = dir.path().join("will_delete.txt");
188 std::fs::write(&path, "content").unwrap();
189
190 let tracker = FileTracker::new();
191 tracker.record_read(&path).unwrap();
192
193 std::fs::remove_file(&path).unwrap();
195
196 let err = tracker.check_unmodified(&path).unwrap_err();
198 assert!(err.contains("has been modified"), "got: {err}");
199 }
200
201 #[test]
202 fn check_unmodified_fails_when_file_created_after_nonexistent_read() {
203 let dir = tempfile::tempdir().unwrap();
204 let path = dir.path().join("will_appear.txt");
205
206 let tracker = FileTracker::new();
207 tracker.record_read(&path).unwrap();
209
210 std::fs::write(&path, "surprise").unwrap();
212
213 let err = tracker.check_unmodified(&path).unwrap_err();
215 assert!(err.contains("has been modified"), "got: {err}");
216 }
217}