use std::collections::HashMap;
use std::path::Path;
use std::sync::LazyLock;
use std::time::SystemTime;
use dashmap::DashMap;
use lingshu_types::ToolError;
#[derive(Clone, Debug, PartialEq, Eq)]
struct FileSnapshot {
exists: bool,
len: u64,
modified: Option<SystemTime>,
}
type DedupKey = (String, Option<u64>, Option<u64>);
#[derive(Default)]
struct ReadTrackState {
last_key: Option<String>,
consecutive: u32,
file_snapshots: HashMap<String, FileSnapshot>,
read_dedup: HashMap<DedupKey, SystemTime>,
}
static TRACKER: LazyLock<DashMap<String, ReadTrackState>> = LazyLock::new(DashMap::new);
fn path_key(path: &Path) -> String {
path.to_string_lossy().into_owned()
}
fn snapshot_for_path(path: &Path) -> Result<FileSnapshot, ToolError> {
match std::fs::metadata(path) {
Ok(meta) => Ok(FileSnapshot {
exists: meta.is_file(),
len: meta.len(),
modified: meta.modified().ok(),
}),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(FileSnapshot {
exists: false,
len: 0,
modified: None,
}),
Err(err) => Err(ToolError::Other(format!(
"Cannot stat '{}': {}",
path.display(),
err
))),
}
}
pub fn check_and_update(session_id: &str, key: String) -> u32 {
let mut state = TRACKER.entry(session_id.to_string()).or_default();
if state.last_key.as_deref() == Some(&key) {
state.consecutive += 1;
} else {
state.last_key = Some(key);
state.consecutive = 1;
}
state.consecutive
}
pub fn notify_other_tool_call(session_id: &str) {
if let Some(mut state) = TRACKER.get_mut(session_id) {
state.last_key = None;
state.consecutive = 0;
}
}
pub fn read_key(path: &str, line_start: Option<usize>, line_end: Option<usize>) -> String {
format!(
"read:{}:{}:{}",
path,
line_start.unwrap_or(0),
line_end.unwrap_or(0)
)
}
pub fn search_key(
pattern: &str,
path: Option<&str>,
include: Option<&str>,
max_results: usize,
) -> String {
format!(
"search:{}:{}:{}:{}",
pattern,
path.unwrap_or(""),
include.unwrap_or(""),
max_results
)
}
pub fn record_file_snapshot(session_id: &str, path: &Path) -> Result<(), ToolError> {
let snapshot = snapshot_for_path(path)?;
let mut state = TRACKER.entry(session_id.to_string()).or_default();
state.file_snapshots.insert(path_key(path), snapshot);
Ok(())
}
pub fn clear_file_snapshot(session_id: &str, path: &Path) {
if let Some(mut state) = TRACKER.get_mut(session_id) {
state.file_snapshots.remove(&path_key(path));
}
}
pub fn has_file_snapshot(session_id: &str, path: &Path) -> bool {
TRACKER
.get(session_id)
.map(|state| state.file_snapshots.contains_key(&path_key(path)))
.unwrap_or(false)
}
pub fn guard_file_freshness(
session_id: &str,
tool: &str,
display_path: &str,
path: &Path,
) -> Result<(), ToolError> {
let Some(state) = TRACKER.get(session_id) else {
return Ok(());
};
let Some(previous) = state.file_snapshots.get(&path_key(path)).cloned() else {
return Ok(());
};
drop(state);
let current = snapshot_for_path(path)?;
if current == previous {
return Ok(());
}
Err(crate::recovery_catalog::stale_file_context(tool, display_path))
}
pub fn record_read_dedup(
session_id: &str,
path: &Path,
start_line: Option<u64>,
end_line: Option<u64>,
) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
let mtime = meta.modified().ok();
let Some(mtime) = mtime else { return };
let key: DedupKey = (path_key(path), start_line, end_line);
let mut state = TRACKER.entry(session_id.to_string()).or_default();
state.read_dedup.insert(key, mtime);
}
pub fn check_read_dedup(
session_id: &str,
path: &Path,
start_line: Option<u64>,
end_line: Option<u64>,
) -> Option<String> {
let state = TRACKER.get(session_id)?;
let key: DedupKey = (path_key(path), start_line, end_line);
let cached_mtime = *state.read_dedup.get(&key)?;
drop(state);
let current_mtime = std::fs::metadata(path).ok()?.modified().ok()?;
if current_mtime != cached_mtime {
return None;
}
let range_desc = match (start_line, end_line) {
(Some(s), Some(e)) => format!(" (lines {s}–{e})"),
(Some(s), None) => format!(" (from line {s})"),
_ => String::new(),
};
Some(format!(
"[File unchanged since last read] '{}'{range_desc} has not been modified. \
The content is already in your context from a previous read_file call. \
Proceed using the content you already have rather than re-reading.",
path.display()
))
}
pub fn reset_read_dedup(session_id: &str) {
if let Some(mut state) = TRACKER.get_mut(session_id) {
state.read_dedup.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn first_call_returns_one() {
let session = "rt-test-1";
let key = read_key("foo.rs", None, None);
assert_eq!(check_and_update(session, key), 1);
}
#[test]
fn consecutive_same_key_increments() {
let session = "rt-test-2";
let key = || read_key("bar.rs", Some(1), Some(50));
assert_eq!(check_and_update(session, key()), 1);
assert_eq!(check_and_update(session, key()), 2);
assert_eq!(check_and_update(session, key()), 3);
assert_eq!(check_and_update(session, key()), 4);
}
#[test]
fn different_key_resets_count() {
let session = "rt-test-3";
assert_eq!(check_and_update(session, read_key("a.rs", None, None)), 1);
assert_eq!(check_and_update(session, read_key("a.rs", None, None)), 2);
assert_eq!(check_and_update(session, read_key("b.rs", None, None)), 1);
assert_eq!(check_and_update(session, read_key("a.rs", None, None)), 1);
}
#[test]
fn notify_resets_count() {
let session = "rt-test-4";
let key = || read_key("reset.rs", None, None);
check_and_update(session, key());
check_and_update(session, key());
assert_eq!(check_and_update(session, key()), 3);
notify_other_tool_call(session);
assert_eq!(check_and_update(session, key()), 1);
}
#[test]
fn freshness_guard_detects_external_modification_after_read() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("freshness.txt");
std::fs::write(&path, "original content").expect("seed");
let session = "rt-test-5";
record_file_snapshot(session, &path).expect("record snapshot");
std::fs::write(&path, "modified content with different length").expect("modify");
let err = guard_file_freshness(session, "write_file", "freshness.txt", &path)
.expect_err("stale guard should fire");
assert!(err.to_string().contains("changed since it was last read"));
}
#[test]
fn freshness_guard_is_cleared_after_delete() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("deleted.txt");
std::fs::write(&path, "v1").expect("seed");
let session = "rt-test-6";
record_file_snapshot(session, &path).expect("record snapshot");
clear_file_snapshot(session, &path);
std::fs::remove_file(&path).expect("delete");
guard_file_freshness(session, "write_file", "deleted.txt", &path)
.expect("cleared snapshot should not block");
}
#[test]
fn has_file_snapshot_reflects_record_and_clear() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("snapshot.txt");
std::fs::write(&path, "v1").expect("seed");
let session = "rt-test-7";
assert!(!has_file_snapshot(session, &path));
record_file_snapshot(session, &path).expect("record snapshot");
assert!(has_file_snapshot(session, &path));
clear_file_snapshot(session, &path);
assert!(!has_file_snapshot(session, &path));
}
#[test]
fn read_dedup_returns_none_on_first_read() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("dedup_first.txt");
std::fs::write(&path, "hello").expect("seed");
let session = "rt-dedup-1";
assert!(
check_read_dedup(session, &path, None, None).is_none(),
"first read must not be deduped"
);
}
#[test]
fn read_dedup_returns_stub_on_unchanged_file() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("dedup_unchanged.txt");
std::fs::write(&path, "content").expect("seed");
let session = "rt-dedup-2";
record_read_dedup(session, &path, None, None);
let result = check_read_dedup(session, &path, None, None);
assert!(result.is_some(), "unchanged file must produce dedup stub");
let stub = result.unwrap();
assert!(
stub.contains("unchanged since last read"),
"stub must mention unchanged"
);
assert!(
stub.contains("already in your context"),
"stub must guide model"
);
}
#[test]
fn read_dedup_allows_read_after_file_modified() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("dedup_modified.txt");
std::fs::write(&path, "v1").expect("seed");
let session = "rt-dedup-3";
record_read_dedup(session, &path, None, None);
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&path, "v2").expect("modify");
assert!(
check_read_dedup(session, &path, None, None).is_none(),
"modified file must not be deduped"
);
}
#[test]
fn read_dedup_separate_ranges_are_independent() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("dedup_ranges.txt");
std::fs::write(&path, "line1\nline2\nline3").expect("seed");
let session = "rt-dedup-4";
record_read_dedup(session, &path, Some(1), Some(2));
assert!(check_read_dedup(session, &path, Some(1), Some(2)).is_some());
assert!(check_read_dedup(session, &path, Some(1), Some(3)).is_none());
assert!(check_read_dedup(session, &path, None, None).is_none());
}
#[test]
fn reset_read_dedup_clears_cache_after_compression() {
let dir = TempDir::new().expect("tmpdir");
let path = dir.path().join("dedup_reset.txt");
std::fs::write(&path, "content").expect("seed");
let session = "rt-dedup-5";
record_read_dedup(session, &path, None, None);
assert!(check_read_dedup(session, &path, None, None).is_some());
reset_read_dedup(session);
assert!(
check_read_dedup(session, &path, None, None).is_none(),
"after compression reset, dedup cache must be cleared"
);
}
#[test]
fn reset_read_dedup_noop_for_unknown_session() {
reset_read_dedup("rt-unknown-session-xyz");
}
}