Skip to main content

bamboo_tools/tools/
read_tracker.rs

1use 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            // Keep a sentinel entry so we still know a read happened.
92            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        // A real path is canonicalized to the resolved absolute form.
181        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        // A non-existent path cannot be canonicalized and falls back to the
187        // input string unchanged (preserves the prior std::fs behavior).
188        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}