use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone)]
struct ReadRecord {
mtime_ms: Option<u64>,
offset: Option<u64>,
limit: Option<u64>,
}
fn to_millis(t: SystemTime) -> Option<u64> {
t.duration_since(UNIX_EPOCH)
.ok()
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
#[derive(Debug, Default)]
pub(crate) struct ClaudeSessionState {
entries: Mutex<HashMap<PathBuf, ReadRecord>>,
}
impl ClaudeSessionState {
pub(crate) fn record_read(
&self,
path: PathBuf,
modified: Option<SystemTime>,
offset: Option<u64>,
limit: Option<u64>,
) {
let record = ReadRecord {
mtime_ms: modified.and_then(to_millis),
offset,
limit,
};
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(path, record);
}
pub(crate) fn record_write(&self, path: PathBuf, modified: Option<SystemTime>) {
let record = ReadRecord {
mtime_ms: modified.and_then(to_millis),
offset: None,
limit: None,
};
self.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(path, record);
}
pub(crate) fn check_fresh(&self, path: &Path, current: Option<SystemTime>) -> Option<bool> {
let entries = self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let record = entries.get(path)?;
match (record.mtime_ms, current.and_then(to_millis)) {
(Some(stored), Some(now)) => Some(now <= stored),
_ => Some(true),
}
}
pub(crate) fn is_unchanged_read(
&self,
path: &Path,
modified: Option<SystemTime>,
offset: Option<u64>,
limit: Option<u64>,
) -> bool {
let Some(now_ms) = modified.and_then(to_millis) else {
return false;
};
let entries = self
.entries
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let Some(record) = entries.get(path) else {
return false;
};
record.offset.is_some()
&& record.offset == offset
&& record.limit == limit
&& record.mtime_ms == Some(now_ms)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
fn t(ms: u64) -> SystemTime {
UNIX_EPOCH + Duration::from_millis(ms)
}
#[test]
fn unchanged_read_matches_same_window_and_mtime() {
let state = ClaudeSessionState::default();
let p = PathBuf::from("/f.txt");
state.record_read(p.clone(), Some(t(1000)), Some(1), None);
assert!(state.is_unchanged_read(&p, Some(t(1000)), Some(1), None));
assert!(!state.is_unchanged_read(&p, Some(t(2000)), Some(1), None));
assert!(!state.is_unchanged_read(&p, Some(t(1000)), Some(5), None));
assert!(!state.is_unchanged_read(&p, None, Some(1), None));
assert!(!state.is_unchanged_read(Path::new("/other"), Some(t(1000)), Some(1), None));
}
#[test]
fn check_fresh_gate_transitions() {
let state = ClaudeSessionState::default();
let p = PathBuf::from("/f.txt");
assert_eq!(state.check_fresh(&p, Some(t(1000))), None);
state.record_read(p.clone(), Some(t(1000)), Some(1), None);
assert_eq!(state.check_fresh(&p, Some(t(1000))), Some(true));
assert_eq!(state.check_fresh(&p, Some(t(2000))), Some(false));
state.record_write(p.clone(), Some(t(2000)));
assert_eq!(state.check_fresh(&p, Some(t(2000))), Some(true));
assert_ne!(state.check_fresh(&p, Some(t(2000))), None);
}
#[test]
fn write_origin_entry_never_dedup_matches_a_read() {
let state = ClaudeSessionState::default();
let p = PathBuf::from("/f.txt");
state.record_write(p.clone(), Some(t(1000)));
assert!(!state.is_unchanged_read(&p, Some(t(1000)), Some(1), None));
}
#[test]
fn sub_millisecond_change_is_flattened_like_cc() {
let state = ClaudeSessionState::default();
let p = PathBuf::from("/f.txt");
state.record_read(p.clone(), Some(t(1000)), Some(1), None);
assert!(state.is_unchanged_read(
&p,
Some(UNIX_EPOCH + Duration::from_micros(1_000_900)),
Some(1),
None
));
}
}