1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
|
//! Tracks which files have been read in the current session, for freshness
//! checking before edits.
//!
//! The tracker records the content hash and mtime of each file at the time
//! it was last read. Before an edit, the tracker verifies the file hasn't
//! changed since the last read — catching both external modifications and
//! concurrent tool calls.
//!
//! Persisted as JSON in session metadata so it survives across CLI
//! invocations within the same logical session.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use eyre::Result;
use serde::{Deserialize, Serialize};
/// Metadata key used for session_metadata persistence.
pub(crate) const METADATA_KEY: &str = "file_read_tracker";
/// State recorded for a single file read.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct FileReadState {
/// Hash of the file contents at the time of the last read.
pub content_hash: u64,
/// File mtime (as milliseconds since epoch) at the time of the last read.
/// Millisecond precision ensures sub-second modifications are detected.
pub mtime_ms: i64,
}
/// Tracks file read state for freshness checking.
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub(crate) struct FileReadTracker {
reads: HashMap<PathBuf, FileReadState>,
}
/// Result of a freshness check.
pub(crate) enum FreshnessCheck {
/// File is fresh — the content hasn't changed since the last read.
Fresh,
/// File has never been read in this session.
NotRead,
/// File has been modified since the last read.
Stale,
}
impl FileReadTracker {
/// Record that a file was read. Call this after a successful `read_file`
/// execution. The `path` should be canonical (absolute, tilde-expanded).
pub fn record_read(&mut self, path: PathBuf, content: &[u8], mtime: SystemTime) {
let content_hash = hash_content(content);
let mtime_ms = system_time_to_ms(mtime);
self.reads.insert(
path,
FileReadState {
content_hash,
mtime_ms,
},
);
}
/// Check whether a file is fresh (unchanged since last read).
///
/// Uses mtime as a fast path — only re-hashes if mtime differs.
pub fn check_freshness(&self, path: &Path) -> Result<FreshnessCheck> {
let state = match self.reads.get(path) {
Some(s) => s,
None => return Ok(FreshnessCheck::NotRead),
};
// Stat the file
let metadata = match std::fs::metadata(path) {
Ok(m) => m,
Err(_) => return Ok(FreshnessCheck::Stale), // file deleted or inaccessible
};
let current_mtime_ms =
system_time_to_ms(metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH));
// Fast path: mtime unchanged → fresh
if current_mtime_ms == state.mtime_ms {
return Ok(FreshnessCheck::Fresh);
}
// Mtime changed — re-hash to confirm
let content = std::fs::read(path)?;
let current_hash = hash_content(&content);
if current_hash == state.content_hash {
Ok(FreshnessCheck::Fresh)
} else {
Ok(FreshnessCheck::Stale)
}
}
/// Update the tracker entry after a successful edit (new content written).
pub fn update_after_edit(&mut self, path: &Path, new_content: &[u8], new_mtime: SystemTime) {
let content_hash = hash_content(new_content);
let mtime_ms = system_time_to_ms(new_mtime);
self.reads.insert(
path.to_path_buf(),
FileReadState {
content_hash,
mtime_ms,
},
);
}
/// Serialize to JSON for session metadata persistence.
pub fn to_json(&self) -> Result<String> {
Ok(serde_json::to_string(self)?)
}
/// Deserialize from JSON session metadata.
pub fn from_json(json: &str) -> Result<Self> {
Ok(serde_json::from_str(json)?)
}
}
fn system_time_to_ms(t: SystemTime) -> i64 {
t.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn hash_content(content: &[u8]) -> u64 {
xxhash_rust::xxh3::xxh3_64(content)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn record_and_check_fresh() {
let mut tracker = FileReadTracker::default();
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "hello world").unwrap();
let path = tmp.path().to_path_buf();
let content = std::fs::read(&path).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
tracker.record_read(path.clone(), &content, mtime);
assert!(matches!(
tracker.check_freshness(&path).unwrap(),
FreshnessCheck::Fresh
));
}
#[test]
fn check_not_read() {
let tracker = FileReadTracker::default();
let path = PathBuf::from("/nonexistent/file.txt");
assert!(matches!(
tracker.check_freshness(&path).unwrap(),
FreshnessCheck::NotRead
));
}
#[test]
fn check_stale_after_modification() {
let mut tracker = FileReadTracker::default();
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "original").unwrap();
let path = tmp.path().to_path_buf();
let content = std::fs::read(&path).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
tracker.record_read(path.clone(), &content, mtime);
// Small delay to ensure the filesystem mtime advances
std::thread::sleep(std::time::Duration::from_millis(10));
// Modify the file
std::fs::write(&path, "modified").unwrap();
assert!(matches!(
tracker.check_freshness(&path).unwrap(),
FreshnessCheck::Stale
));
}
#[test]
fn update_after_edit_makes_fresh() {
let mut tracker = FileReadTracker::default();
let mut tmp = NamedTempFile::new().unwrap();
write!(tmp, "original").unwrap();
let path = tmp.path().to_path_buf();
let content = std::fs::read(&path).unwrap();
let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
tracker.record_read(path.clone(), &content, mtime);
// Simulate an edit
let new_content = b"edited content";
std::fs::write(&path, new_content).unwrap();
let new_mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
tracker.update_after_edit(&path, new_content, new_mtime);
assert!(matches!(
tracker.check_freshness(&path).unwrap(),
FreshnessCheck::Fresh
));
}
#[test]
fn roundtrip_json() {
let mut tracker = FileReadTracker::default();
tracker.reads.insert(
PathBuf::from("/some/file.toml"),
FileReadState {
content_hash: 12345,
mtime_ms: 1700000000000,
},
);
let json = tracker.to_json().unwrap();
let restored = FileReadTracker::from_json(&json).unwrap();
assert_eq!(restored.reads.len(), 1);
assert_eq!(
restored.reads[&PathBuf::from("/some/file.toml")].content_hash,
12345
);
}
}
|