bamboo_tools/tools/
read_tracker.rs1use dashmap::DashMap;
2use std::collections::HashMap;
3use std::sync::{Arc, OnceLock};
4use std::time::Instant;
5use tokio::sync::Mutex;
6
7const MAX_TRACKED_SESSIONS: usize = 2_000;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ReadState {
11 Unread,
12 Stale,
13 Fresh,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17struct FileSnapshot {
18 size_bytes: u64,
19 modified_ns: Option<u128>,
20}
21
22#[derive(Debug, Default)]
23struct SessionReads {
24 files: HashMap<String, FileSnapshot>,
25 last_touched: Option<Instant>,
26}
27
28fn tracker() -> &'static DashMap<String, Arc<Mutex<SessionReads>>> {
29 static TRACKER: OnceLock<DashMap<String, Arc<Mutex<SessionReads>>>> = OnceLock::new();
30 TRACKER.get_or_init(DashMap::new)
31}
32
33async fn normalize_path(path: &str) -> String {
34 tokio::fs::canonicalize(path)
35 .await
36 .ok()
37 .and_then(|value| value.to_str().map(|s| s.to_string()))
38 .unwrap_or_else(|| path.to_string())
39}
40
41async fn snapshot_for_path(path: &str) -> Option<FileSnapshot> {
42 let metadata = tokio::fs::metadata(path).await.ok()?;
43 let modified_ns = metadata
44 .modified()
45 .ok()
46 .and_then(|value| value.duration_since(std::time::UNIX_EPOCH).ok())
47 .map(|duration| duration.as_nanos());
48
49 Some(FileSnapshot {
50 size_bytes: metadata.len(),
51 modified_ns,
52 })
53}
54
55async fn cleanup_if_needed() {
56 let map = tracker();
57 if map.len() <= MAX_TRACKED_SESSIONS {
58 return;
59 }
60
61 let mut oldest: Option<(String, Instant)> = None;
62 for entry in map.iter() {
63 let key = entry.key().clone();
64 let session = entry.value().clone();
65 let touched = session.lock().await.last_touched.unwrap_or(Instant::now());
66 match oldest {
67 Some((_, ts)) if touched >= ts => {}
68 _ => oldest = Some((key, touched)),
69 }
70 }
71
72 if let Some((key, _)) = oldest {
73 map.remove(&key);
74 }
75}
76
77pub async fn mark_read(session_id: &str, path: &str) {
78 let normalized = normalize_path(path).await;
79 let snapshot = snapshot_for_path(path).await;
80 let entry = tracker()
81 .entry(session_id.to_string())
82 .or_insert_with(|| Arc::new(Mutex::new(SessionReads::default())))
83 .clone();
84
85 {
86 let mut guard = entry.lock().await;
87 guard.last_touched = Some(Instant::now());
88 if let Some(snapshot) = snapshot {
89 guard.files.insert(normalized, snapshot);
90 } else {
91 guard.files.insert(
93 normalized,
94 FileSnapshot {
95 size_bytes: 0,
96 modified_ns: None,
97 },
98 );
99 }
100 }
101
102 cleanup_if_needed().await;
103}
104
105pub async fn has_read(session_id: &str, path: &str) -> bool {
106 let normalized = normalize_path(path).await;
107 let Some(entry) = tracker().get(session_id).map(|value| value.clone()) else {
108 return false;
109 };
110
111 let mut guard = entry.lock().await;
112 guard.last_touched = Some(Instant::now());
113 guard.files.contains_key(&normalized)
114}
115
116pub async fn read_state(session_id: &str, path: &str) -> ReadState {
117 let normalized = normalize_path(path).await;
118 let Some(entry) = tracker().get(session_id).map(|value| value.clone()) else {
119 return ReadState::Unread;
120 };
121
122 let mut guard = entry.lock().await;
123 guard.last_touched = Some(Instant::now());
124 let Some(snapshot) = guard.files.get(&normalized).copied() else {
125 return ReadState::Unread;
126 };
127
128 let Some(current) = snapshot_for_path(path).await else {
129 return ReadState::Stale;
130 };
131
132 if snapshot == current {
133 ReadState::Fresh
134 } else {
135 ReadState::Stale
136 }
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 fn session_id(label: &str) -> String {
144 format!("read-tracker-{label}-{}", uuid::Uuid::new_v4())
145 }
146
147 #[tokio::test]
148 async fn read_state_transitions_from_fresh_to_stale_after_external_change() {
149 let file = tempfile::NamedTempFile::new().unwrap();
150 tokio::fs::write(file.path(), "v1").await.unwrap();
151 let path = file.path().to_string_lossy().to_string();
152 let session = session_id("fresh-stale");
153
154 mark_read(&session, &path).await;
155 assert!(has_read(&session, &path).await);
156 assert_eq!(read_state(&session, &path).await, ReadState::Fresh);
157
158 tokio::fs::write(file.path(), "v2 changed").await.unwrap();
159 assert_eq!(read_state(&session, &path).await, ReadState::Stale);
160 }
161
162 #[tokio::test]
163 async fn missing_file_marked_read_is_treated_as_stale_for_writes() {
164 let dir = tempfile::tempdir().unwrap();
165 let path = dir.path().join("missing.txt");
166 let path_str = path.to_string_lossy().to_string();
167 let session = session_id("missing");
168
169 mark_read(&session, &path_str).await;
170 assert!(has_read(&session, &path_str).await);
171 assert_eq!(read_state(&session, &path_str).await, ReadState::Stale);
172 }
173
174 #[tokio::test]
175 async fn normalize_path_canonicalizes_real_file_and_falls_back_for_missing() {
176 let dir = tempfile::tempdir().unwrap();
177 let file_path = dir.path().join("real.txt");
178 tokio::fs::write(&file_path, "hello").await.unwrap();
179
180 let raw = file_path.to_string_lossy().to_string();
182 let normalized = normalize_path(&raw).await;
183 let canonical = tokio::fs::canonicalize(&file_path).await.unwrap();
184 assert_eq!(normalized, canonical.to_string_lossy().to_string());
185
186 let missing = dir.path().join("does_not_exist.txt");
189 let missing_str = missing.to_string_lossy().to_string();
190 assert_eq!(normalize_path(&missing_str).await, missing_str);
191 }
192}