use async_trait::async_trait;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
use crate::acp::ToolKind;
use crate::error::{Error, Result};
use crate::llm::ToolSpec;
const READ_FILE_STATE_MAX_ENTRIES: usize = 100;
#[derive(Debug, Clone)]
pub struct ReadRecord {
pub is_partial: bool,
pub content: String,
pub timestamp: u64,
}
#[derive(Debug, Default, Clone)]
pub struct ReadFileState {
records: HashMap<PathBuf, ReadRecord>,
insertion_order: Vec<PathBuf>,
}
impl ReadFileState {
pub fn new() -> Self {
Self::default()
}
pub fn record(&mut self, path: PathBuf, is_partial: bool, content: String, timestamp: u64) {
if self.records.contains_key(&path) {
self.insertion_order.retain(|p| p != &path);
}
self.records.insert(
path.clone(),
ReadRecord {
is_partial,
content,
timestamp,
},
);
self.insertion_order.push(path);
while self.insertion_order.len() > READ_FILE_STATE_MAX_ENTRIES {
if let Some(oldest) = self.insertion_order.first().cloned() {
self.insertion_order.remove(0);
self.records.remove(&oldest);
}
}
}
pub fn get(&self, path: &Path) -> Option<&ReadRecord> {
self.records.get(path)
}
pub fn recent_files(&self, n: usize) -> Vec<(PathBuf, String)> {
self.insertion_order
.iter()
.rev()
.take(n)
.filter_map(|p| self.records.get(p).map(|r| (p.clone(), r.content.clone())))
.collect()
}
}
pub(crate) fn get_file_mtime(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[derive(Debug, Clone)]
pub struct ReadFile {
pub root: PathBuf,
pub extra_roots: Vec<(PathBuf, AccessTier)>,
pub session_roots: Option<SharedSandboxRoots>,
pub max_bytes: usize,
pub read_state: Option<Arc<Mutex<ReadFileState>>>,
}
impl ReadFile {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
extra_roots: Vec::new(),
session_roots: None,
max_bytes: 256 * 1024,
read_state: None,
}
}
pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
self.read_state = Some(slot);
self
}
pub fn with_extra_roots(
mut self,
extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
) -> Self {
self.extra_roots.extend(extra);
self
}
pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
self.session_roots = Some(slot);
self
}
pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
if let Some(s) = slot {
self.session_roots = Some(s);
}
self
}
fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
v.push((self.root.clone(), AccessTier::ReadWrite));
v.extend(self.extra_roots.iter().cloned());
if let Some(slot) = &self.session_roots {
if let Ok(roots) = slot.read() {
v.extend(roots.iter().cloned());
}
}
v
}
}
#[async_trait]
impl Tool for ReadFile {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "Read".into(),
description:
"Read a UTF-8 text file under the workspace. Optionally return a line range."
.to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to the workspace root"},
"start_line": {"type": "integer", "description": "Optional 1-indexed inclusive start line. If end_line is set but not start_line, defaults to 1."},
"end_line": {"type": "integer", "description": "Optional 1-indexed inclusive end line. If start_line is set but not end_line, defaults to last line."}
},
"required": ["path"]
}),
}
}
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::ReadOnly
}
fn kind(&self) -> ToolKind {
ToolKind::Read
}
async fn execute(&self, args: Value) -> Result<String> {
let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
name: "Read".into(),
message: "missing `path`".into(),
})?;
let abs = resolve_within_any(&self.all_roots(), path, false)?;
let bytes = tokio::fs::read(&abs).await.map_err(|e| Error::Tool {
name: "Read".into(),
call_id: None,
message: format!("{}: {e}", abs.display()),
})?;
if bytes.len() > self.max_bytes {
return Err(Error::Tool {
name: "Read".into(),
call_id: None,
message: format!(
"file too large: {} bytes (max {})",
bytes.len(),
self.max_bytes
),
});
}
let content = String::from_utf8(bytes).map_err(|e| Error::Tool {
name: "Read".into(),
call_id: None,
message: format!("not utf-8: {e}"),
})?;
let mtime = get_file_mtime(&abs);
let start_line = args["start_line"].as_u64();
let end_line = args["end_line"].as_u64();
if start_line.is_none() && end_line.is_none() {
if let Some(slot) = &self.read_state {
if let Ok(mut state) = slot.lock() {
state.record(abs.clone(), false, content.clone(), mtime);
}
}
return Ok(content);
}
let total_lines = content.lines().count();
if total_lines == 0 {
if let Some(slot) = &self.read_state {
if let Ok(mut state) = slot.lock() {
state.record(abs.clone(), false, content.clone(), mtime);
}
}
return Ok(content);
}
let start = match start_line {
Some(0) => {
return Err(Error::BadToolArgs {
name: "Read".to_string(),
message: "start_line must be >= 1 (1-indexed)".to_string(),
});
}
Some(n) => n as usize,
None => 1,
};
let end = match end_line {
Some(0) => {
return Err(Error::BadToolArgs {
name: "Read".to_string(),
message: "end_line must be >= 1 (1-indexed)".to_string(),
});
}
Some(n) => n as usize,
None => total_lines,
};
if start > end {
return Err(Error::BadToolArgs {
name: "Read".to_string(),
message: format!("start_line ({}) must be <= end_line ({})", start, end),
});
}
let start = start.min(total_lines);
let end = end.min(total_lines);
if start_line.is_some() && start > total_lines {
return Err(Error::BadToolArgs {
name: "Read".to_string(),
message: format!("start_line {} exceeds total lines {}", start, total_lines),
});
}
let is_partial = !(start == 1 && end == total_lines);
if let Some(slot) = &self.read_state {
if let Ok(mut state) = slot.lock() {
state.record(abs.clone(), is_partial, content.clone(), mtime);
}
}
let slice: String = content
.lines()
.skip(start - 1)
.take(end - start + 1)
.collect::<Vec<_>>()
.join("\n");
Ok(format!(
"# range: lines {}-{} of {}\n{}",
start, end, total_lines, slice
))
}
}
#[derive(Debug, Clone)]
pub struct WriteFile {
pub root: PathBuf,
pub extra_roots: Vec<(PathBuf, AccessTier)>,
pub session_roots: Option<SharedSandboxRoots>,
pub read_state: Option<Arc<Mutex<ReadFileState>>>,
}
impl WriteFile {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
extra_roots: Vec::new(),
session_roots: None,
read_state: None,
}
}
pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
self.read_state = Some(slot);
self
}
pub fn with_extra_roots(
mut self,
extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
) -> Self {
self.extra_roots.extend(extra);
self
}
pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
self.session_roots = Some(slot);
self
}
pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
if let Some(s) = slot {
self.session_roots = Some(s);
}
self
}
fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
v.push((self.root.clone(), AccessTier::ReadWrite));
v.extend(self.extra_roots.iter().cloned());
if let Some(slot) = &self.session_roots {
if let Ok(roots) = slot.read() {
v.extend(roots.iter().cloned());
}
}
v
}
}
#[async_trait]
impl Tool for WriteFile {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "Write".into(),
description: "Write/overwrite a UTF-8 text file under the workspace. \
Parent directories are created.\n\
If the file already exists, you MUST read it first via the \
`Read` tool. This tool will error if you did not read an \
existing file before writing to it."
.into(),
parameters: json!({
"type": "object",
"properties": {
"path": {"type": "string", "description": "Path relative to the workspace root"},
"contents": {"type": "string", "description": "Full new contents of the file"}
},
"required": ["path", "contents"]
}),
}
}
fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
crate::tools::ToolSideEffect::Mutating
}
fn kind(&self) -> ToolKind {
ToolKind::Write
}
async fn execute(&self, args: Value) -> Result<String> {
let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
name: "Write".into(),
message: "missing `path`".into(),
})?;
let contents = args["contents"]
.as_str()
.ok_or_else(|| Error::BadToolArgs {
name: "Write".into(),
message: "missing `contents`".into(),
})?;
let abs = resolve_within_any(&self.all_roots(), path, true)?;
let file_exists = abs.exists();
if file_exists {
if let Some(slot) = &self.read_state {
let staleness_check: Option<(u64, String)> = {
let state = slot.lock().map_err(|_| Error::Tool {
name: "Write".into(),
call_id: None,
message: "internal: read-state lock poisoned".into(),
})?;
match state.get(&abs) {
None => {
return Err(Error::Tool {
name: "Write".into(),
call_id: None,
message: format!(
"File `{path}` has not been read yet. \
Read it first before writing to it."
),
});
}
Some(record) if record.is_partial => {
return Err(Error::Tool {
name: "Write".into(),
call_id: None,
message: format!(
"File `{path}` was only partially read \
(line range). Read the complete file before writing."
),
});
}
Some(record) => {
let disk_mtime = get_file_mtime(&abs);
if disk_mtime > record.timestamp {
Some((disk_mtime, record.content.clone()))
} else {
None
}
}
}
};
if let Some((_disk_mtime, cached_content)) = staleness_check {
let disk_content = tokio::fs::read_to_string(&abs).await.unwrap_or_default();
if disk_content != cached_content {
return Err(Error::Tool {
name: "Write".into(),
call_id: None,
message: format!(
"File `{path}` has been modified since it was \
last read. Read it again before writing."
),
});
}
}
}
}
if let Some(parent) = abs.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| Error::Tool {
name: "Write".into(),
call_id: None,
message: format!("mkdir {}: {e}", parent.display()),
})?;
}
if file_exists {
if let Some(slot) = &self.read_state {
let cached: Option<(u64, String)> = {
let state = slot.lock().map_err(|_| Error::Tool {
name: "Write".into(),
call_id: None,
message: "internal: read-state lock poisoned".into(),
})?;
state.get(&abs).map(|r| (r.timestamp, r.content.clone()))
};
if let Some((cached_ts, cached_content)) = cached {
let disk_mtime = get_file_mtime(&abs);
if disk_mtime > cached_ts {
let disk_content =
tokio::fs::read_to_string(&abs).await.unwrap_or_default();
if disk_content != cached_content {
return Err(Error::Tool {
name: "Write".into(),
call_id: None,
message: format!(
"File `{path}` was modified after it was last \
read. Read it again before writing."
),
});
}
}
}
}
}
tokio::fs::write(&abs, contents)
.await
.map_err(|e| Error::Tool {
name: "Write".into(),
call_id: None,
message: format!("{}: {e}", abs.display()),
})?;
if let Some(slot) = &self.read_state {
if let Ok(mut state) = slot.lock() {
let new_mtime = get_file_mtime(&abs);
state.record(abs, false, contents.to_string(), new_mtime);
}
}
Ok(format!("wrote {} bytes to {}", contents.len(), path))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn write_then_read_roundtrip() {
let tmp = TempDir::new().unwrap();
let w = WriteFile::new(tmp.path());
let r = ReadFile::new(tmp.path());
w.execute(json!({"path":"hello.txt","contents":"world"}))
.await
.unwrap();
let got = r.execute(json!({"path":"hello.txt"})).await.unwrap();
assert_eq!(got, "world");
}
#[tokio::test]
async fn write_creates_parent_dirs() {
let tmp = TempDir::new().unwrap();
WriteFile::new(tmp.path())
.execute(json!({"path":"a/b/c.txt","contents":"x"}))
.await
.unwrap();
assert!(tmp.path().join("a/b/c.txt").exists());
}
#[tokio::test]
async fn rejects_escape() {
let tmp = TempDir::new().unwrap();
let r = ReadFile::new(tmp.path());
let err = r
.execute(json!({"path":"../etc/passwd"}))
.await
.unwrap_err();
assert!(matches!(err, Error::BadToolArgs { .. }));
}
#[tokio::test]
async fn read_file_with_line_range() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("test.txt"),
"line1
line2
line3
line4
line5
",
)
.unwrap();
let r = ReadFile::new(tmp.path());
let got = r
.execute(json!({"path":"test.txt", "start_line": 2, "end_line": 3}))
.await
.unwrap();
assert!(got.starts_with(
"# range: lines 2-3 of 5
"
));
assert!(got.contains("line2"));
assert!(got.contains("line3"));
assert!(!got.contains("line1"));
assert!(!got.contains("line4"));
assert!(!got.contains("line5"));
}
#[tokio::test]
async fn read_file_without_range_returns_full() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("test.txt"),
"line1
line2
line3",
)
.unwrap();
let r = ReadFile::new(tmp.path());
let got = r.execute(json!({"path":"test.txt"})).await.unwrap();
assert!(!got.starts_with("# range:"));
assert_eq!(
got,
"line1
line2
line3"
);
}
#[tokio::test]
async fn read_file_invalid_range_start_greater_than_end() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("test.txt"),
"line1
line2
line3
",
)
.unwrap();
let r = ReadFile::new(tmp.path());
let err = r
.execute(json!({"path":"test.txt", "start_line": 10, "end_line": 5}))
.await
.unwrap_err();
assert!(matches!(err, Error::BadToolArgs { .. }));
let err_msg = format!("{:?}", err);
assert!(err_msg.contains("start_line") && err_msg.contains("end_line"));
}
#[tokio::test]
async fn read_state_records_full_read() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "line1\nline2\nline3\n").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
r.execute(json!({"path": "f.txt"})).await.unwrap();
let state = slot.lock().unwrap();
let rec = state
.get(&tmp.path().join("f.txt"))
.expect("should be recorded");
assert!(!rec.is_partial, "full read must be is_partial=false");
}
#[tokio::test]
async fn read_state_records_partial_read() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
r.execute(json!({"path": "f.txt", "start_line": 2, "end_line": 5}))
.await
.unwrap();
let state = slot.lock().unwrap();
let rec = state
.get(&tmp.path().join("f.txt"))
.expect("should be recorded");
assert!(rec.is_partial, "line-range read must be is_partial=true");
}
#[tokio::test]
async fn read_state_full_range_not_partial() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "a\nb\nc\nd\ne\n").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
r.execute(json!({"path": "f.txt", "start_line": 1, "end_line": 5}))
.await
.unwrap();
let state = slot.lock().unwrap();
let rec = state
.get(&tmp.path().join("f.txt"))
.expect("should be recorded");
assert!(!rec.is_partial, "start=1 end=N must be is_partial=false");
}
#[tokio::test]
async fn read_file_from_extra_root() {
use crate::tools::AccessTier;
let ws = TempDir::new().unwrap();
let extra = TempDir::new().unwrap();
std::fs::write(extra.path().join("outside.txt"), "hello-from-extra").unwrap();
let r = ReadFile::new(ws.path())
.with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadOnly)]);
let abs = extra.path().join("outside.txt");
let got = r
.execute(json!({"path": abs.to_string_lossy()}))
.await
.unwrap();
assert_eq!(got, "hello-from-extra");
}
#[tokio::test]
async fn write_file_blocked_on_readonly_extra_root() {
use crate::tools::AccessTier;
let ws = TempDir::new().unwrap();
let extra = TempDir::new().unwrap();
let w = WriteFile::new(ws.path())
.with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadOnly)]);
let abs = extra.path().join("new.txt");
let err = w
.execute(json!({"path": abs.to_string_lossy(), "contents": "x"}))
.await
.unwrap_err();
assert!(
err.to_string().contains("read-only"),
"write to read-only extra root must be rejected: {err}"
);
}
#[tokio::test]
async fn write_file_allowed_on_readwrite_extra_root() {
use crate::tools::AccessTier;
let ws = TempDir::new().unwrap();
let extra = TempDir::new().unwrap();
let w = WriteFile::new(ws.path())
.with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadWrite)]);
let abs = extra.path().join("new.txt");
w.execute(json!({"path": abs.to_string_lossy(), "contents": "x"}))
.await
.unwrap();
assert!(abs.exists());
}
#[tokio::test]
async fn read_file_exactly_max_bytes_succeeds() {
let tmp = TempDir::new().unwrap();
let max = 256 * 1024; let content = "x".repeat(max);
std::fs::write(tmp.path().join("big.txt"), content.as_bytes()).unwrap();
let r = ReadFile::new(tmp.path());
let got = r.execute(json!({"path": "big.txt"})).await.unwrap();
assert_eq!(got.len(), max, "file at exactly max_bytes must succeed");
}
#[tokio::test]
async fn read_file_one_over_max_bytes_fails() {
let tmp = TempDir::new().unwrap();
let content = "x".repeat(256 * 1024 + 1);
std::fs::write(tmp.path().join("toobig.txt"), content.as_bytes()).unwrap();
let r = ReadFile::new(tmp.path());
let err = r.execute(json!({"path": "toobig.txt"})).await.unwrap_err();
assert!(
matches!(err, Error::Tool { .. }),
"one byte over max_bytes must return Tool error: {err:?}"
);
}
#[tokio::test]
async fn read_file_start_only_returns_range_from_that_line() {
let tmp = TempDir::new().unwrap();
std::fs::write(
tmp.path().join("f.txt"),
"FIRST\nSECOND\nTHIRD\nFOURTH\nFIFTH",
)
.unwrap();
let r = ReadFile::new(tmp.path());
let got = r
.execute(json!({"path": "f.txt", "start_line": 3}))
.await
.unwrap();
assert!(
got.starts_with("# range:"),
"start_line-only read must produce a range header; got: {got}"
);
assert!(got.contains("THIRD"), "line 3 must be present");
assert!(got.contains("FOURTH"), "line 4 must be present");
assert!(got.contains("FIFTH"), "line 5 must be present");
assert!(!got.contains("FIRST"), "line 1 must be absent");
assert!(!got.contains("SECOND"), "line 2 must be absent");
}
#[tokio::test]
async fn read_file_single_line_range_succeeds() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "alpha\nbeta\ngamma\ndelta\n").unwrap();
let r = ReadFile::new(tmp.path());
let got = r
.execute(json!({"path": "f.txt", "start_line": 2, "end_line": 2}))
.await
.unwrap();
assert!(got.contains("beta"), "line 2 must be present");
assert!(!got.contains("alpha"), "line 1 must be absent");
assert!(!got.contains("gamma"), "line 3 must be absent");
}
#[tokio::test]
async fn read_file_last_line_succeeds() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "first\nsecond\nthird").unwrap();
let r = ReadFile::new(tmp.path());
let got = r
.execute(json!({"path": "f.txt", "start_line": 3, "end_line": 3}))
.await
.unwrap();
assert!(got.contains("third"), "last line must be returned");
assert!(!got.contains("first"), "line 1 must be absent");
assert!(!got.contains("second"), "line 2 must be absent");
}
#[tokio::test]
async fn read_state_partial_from_line_one_not_reaching_end() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\n4\n5\n").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
r.execute(json!({"path": "f.txt", "start_line": 1, "end_line": 3}))
.await
.unwrap();
let state = slot.lock().unwrap();
let rec = state
.get(&tmp.path().join("f.txt"))
.expect("must be recorded");
assert!(
rec.is_partial,
"lines 1-3 of 5 must be recorded as partial (is_partial=true)"
);
}
#[tokio::test]
async fn read_file_start_line_zero_is_invalid() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
let r = ReadFile::new(tmp.path());
let err = r
.execute(json!({"path": "f.txt", "start_line": 0}))
.await
.unwrap_err();
assert!(
matches!(err, Error::BadToolArgs { .. }),
"start_line=0 must return BadToolArgs"
);
}
#[tokio::test]
async fn read_file_end_line_zero_is_invalid() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
let r = ReadFile::new(tmp.path());
let err = r
.execute(json!({"path": "f.txt", "end_line": 0}))
.await
.unwrap_err();
assert!(
matches!(err, Error::BadToolArgs { .. }),
"end_line=0 must return BadToolArgs"
);
}
#[tokio::test]
async fn read_file_start_line_beyond_total_is_invalid() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
let r = ReadFile::new(tmp.path());
let err = r
.execute(json!({"path": "f.txt", "start_line": 999}))
.await
.unwrap_err();
assert!(
matches!(err, Error::BadToolArgs { .. }),
"start_line beyond file length must return BadToolArgs"
);
}
#[tokio::test]
async fn read_file_range_includes_all_lines_through_end() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("f.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
let r = ReadFile::new(tmp.path());
let got = r
.execute(json!({"path": "f.txt", "start_line": 2, "end_line": 5}))
.await
.unwrap();
assert!(got.contains("two"), "line 2 must be present");
assert!(got.contains("three"), "line 3 must be present");
assert!(got.contains("four"), "line 4 must be present");
assert!(got.contains("five"), "line 5 must be present");
assert!(!got.contains("one"), "line 1 must be absent");
}
fn bump_mtime_future(path: &Path) {
use std::time::{Duration, SystemTime};
let future = SystemTime::now() + Duration::from_secs(3600);
let f = std::fs::OpenOptions::new()
.write(true)
.open(path)
.expect("open for set_modified");
f.set_modified(future).expect("set_modified");
}
#[tokio::test]
async fn write_new_file_allowed_without_prior_read() {
let tmp = TempDir::new().unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let w = WriteFile::new(tmp.path()).with_read_state(slot);
w.execute(json!({"path": "fresh.txt", "contents": "hi"}))
.await
.expect("new file write must succeed without prior Read");
assert_eq!(
std::fs::read_to_string(tmp.path().join("fresh.txt")).unwrap(),
"hi"
);
}
#[tokio::test]
async fn write_existing_file_rejected_without_prior_read() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "orig").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
let w = WriteFile::new(tmp.path()).with_read_state(slot);
let err = w
.execute(json!({"path": "e.txt", "contents": "new"}))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("has not been read yet"),
"expected 'has not been read yet', got: {msg}"
);
}
#[tokio::test]
async fn write_existing_file_rejected_when_partial_read() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "line1\nline2\nline3\n").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
ReadFile::new(tmp.path())
.with_read_state(slot.clone())
.execute(json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
.await
.unwrap();
let err = WriteFile::new(tmp.path())
.with_read_state(slot)
.execute(json!({"path": "e.txt", "contents": "new"}))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("partially read"),
"expected 'partially read', got: {msg}"
);
}
#[tokio::test]
async fn write_succeeds_after_full_read() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "orig").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
ReadFile::new(tmp.path())
.with_read_state(slot.clone())
.execute(json!({"path": "e.txt"}))
.await
.unwrap();
WriteFile::new(tmp.path())
.with_read_state(slot)
.execute(json!({"path": "e.txt", "contents": "rewritten"}))
.await
.expect("full read must allow write");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "rewritten");
}
#[tokio::test]
async fn write_rejected_when_file_modified_since_read() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "orig").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
ReadFile::new(tmp.path())
.with_read_state(slot.clone())
.execute(json!({"path": "e.txt"}))
.await
.unwrap();
std::fs::write(&path, "externally changed").unwrap();
bump_mtime_future(&path);
let err = WriteFile::new(tmp.path())
.with_read_state(slot)
.execute(json!({"path": "e.txt", "contents": "new"}))
.await
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("modified") && msg.contains("Read it again"),
"expected staleness rejection, got: {msg}"
);
}
#[tokio::test]
async fn write_post_update_cache_allows_consecutive_write() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "orig").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
ReadFile::new(tmp.path())
.with_read_state(slot.clone())
.execute(json!({"path": "e.txt"}))
.await
.unwrap();
let w = WriteFile::new(tmp.path()).with_read_state(slot);
w.execute(json!({"path": "e.txt", "contents": "v1"}))
.await
.unwrap();
w.execute(json!({"path": "e.txt", "contents": "v2"}))
.await
.expect("consecutive write must succeed without re-read");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "v2");
}
#[tokio::test]
async fn write_stale_mtime_but_unchanged_content_allowed() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("e.txt");
std::fs::write(&path, "same").unwrap();
let slot = Arc::new(Mutex::new(ReadFileState::new()));
ReadFile::new(tmp.path())
.with_read_state(slot.clone())
.execute(json!({"path": "e.txt"}))
.await
.unwrap();
bump_mtime_future(&path);
WriteFile::new(tmp.path())
.with_read_state(slot)
.execute(json!({"path": "e.txt", "contents": "new"}))
.await
.expect("unchanged content with bumped mtime must be allowed");
}
}