Skip to main content

heartbit_core/tool/builtins/
file_tracker.rs

1#![allow(missing_docs)]
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::time::SystemTime;
5
6use parking_lot::RwLock;
7
8/// Tracks when files were last read/written.
9///
10/// Enforces a read-before-write guard: rejects edits to files whose on-disk
11/// mtime has changed since the last recorded read. Shared across read, write,
12/// edit, and patch tools via `Arc<FileTracker>`.
13///
14/// Uses `parking_lot::RwLock` (not tokio) because locks are never held across
15/// `.await` points; `parking_lot` is adopted on this hot path (every read/
16/// write/edit/patch tool call) for ~2× faster uncontended reads, see T2 in
17/// `tasks/performance-audit-heartbit-core-2026-05-06.md`.
18pub struct FileTracker {
19    records: RwLock<HashMap<PathBuf, FileRecord>>,
20}
21
22struct FileRecord {
23    /// On-disk mtime captured at read time.
24    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    /// Record that `path` was just read. Captures its current mtime.
41    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    /// Check that `path` has not been modified since the last recorded read.
57    ///
58    /// Returns `Ok(())` if the file is safe to write. Returns `Err(message)`
59    /// if the file was modified externally or was never read.
60    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    /// Check whether `path` has been previously read.
85    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        // Wait a bit to ensure mtime changes
143        std::thread::sleep(std::time::Duration::from_millis(50));
144
145        // Modify the file externally
146        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        // Modify then re-read
168        std::thread::sleep(std::time::Duration::from_millis(50));
169        std::fs::write(&path, "changed").unwrap();
170        tracker.record_read(&path).unwrap();
171
172        // Should pass because we re-recorded after the change
173        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        // Should not panic — stores None for mtime
181        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        // Delete the file
194        std::fs::remove_file(&path).unwrap();
195
196        // check_unmodified should detect the deletion (Some mtime -> None)
197        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        // Record a "read" of a nonexistent file (None mtime)
208        tracker.record_read(&path).unwrap();
209
210        // Create the file externally
211        std::fs::write(&path, "surprise").unwrap();
212
213        // check_unmodified should detect the creation (None -> Some)
214        let err = tracker.check_unmodified(&path).unwrap_err();
215        assert!(err.contains("has been modified"), "got: {err}");
216    }
217}