1use async_trait::async_trait;
8use serde_json::{json, Value};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
14use crate::acp::ToolKind;
15use crate::error::{Error, Result};
16use crate::llm::ToolSpec;
17
18const READ_FILE_STATE_MAX_ENTRIES: usize = 100;
26
27#[derive(Debug, Clone)]
30pub struct ReadRecord {
31 pub is_partial: bool,
34 pub content: String,
37 pub timestamp: u64,
40}
41
42#[derive(Debug, Default, Clone)]
46pub struct ReadFileState {
47 records: HashMap<PathBuf, ReadRecord>,
48 insertion_order: Vec<PathBuf>,
50}
51
52impl ReadFileState {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn record(&mut self, path: PathBuf, is_partial: bool, content: String, timestamp: u64) {
60 if self.records.contains_key(&path) {
62 self.insertion_order.retain(|p| p != &path);
63 }
64 self.records.insert(
65 path.clone(),
66 ReadRecord {
67 is_partial,
68 content,
69 timestamp,
70 },
71 );
72 self.insertion_order.push(path);
73
74 while self.insertion_order.len() > READ_FILE_STATE_MAX_ENTRIES {
76 if let Some(oldest) = self.insertion_order.first().cloned() {
77 self.insertion_order.remove(0);
78 self.records.remove(&oldest);
79 }
80 }
81 }
82
83 pub fn get(&self, path: &Path) -> Option<&ReadRecord> {
84 self.records.get(path)
85 }
86
87 pub fn recent_files(&self, n: usize) -> Vec<(PathBuf, String)> {
91 self.insertion_order
92 .iter()
93 .rev()
94 .take(n)
95 .filter_map(|p| self.records.get(p).map(|r| (p.clone(), r.content.clone())))
96 .collect()
97 }
98}
99
100pub(crate) fn get_file_mtime(path: &Path) -> u64 {
107 std::fs::metadata(path)
108 .and_then(|m| m.modified())
109 .ok()
110 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
111 .map(|d| d.as_millis() as u64)
112 .unwrap_or(0)
113}
114
115#[derive(Debug, Clone)]
120pub struct ReadFile {
121 pub root: PathBuf,
122 pub extra_roots: Vec<(PathBuf, AccessTier)>,
127 pub session_roots: Option<SharedSandboxRoots>,
131 pub max_bytes: usize,
132 pub read_state: Option<Arc<Mutex<ReadFileState>>>,
135}
136
137impl ReadFile {
138 pub fn new(root: impl Into<PathBuf>) -> Self {
139 Self {
140 root: root.into(),
141 extra_roots: Vec::new(),
142 session_roots: None,
143 max_bytes: 256 * 1024,
144 read_state: None,
145 }
146 }
147
148 pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
149 self.read_state = Some(slot);
150 self
151 }
152
153 pub fn with_extra_roots(
157 mut self,
158 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
159 ) -> Self {
160 self.extra_roots.extend(extra);
161 self
162 }
163
164 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
166 self.session_roots = Some(slot);
167 self
168 }
169
170 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
174 if let Some(s) = slot {
175 self.session_roots = Some(s);
176 }
177 self
178 }
179
180 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
182 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
183 v.push((self.root.clone(), AccessTier::ReadWrite));
184 v.extend(self.extra_roots.iter().cloned());
185 if let Some(slot) = &self.session_roots {
186 if let Ok(roots) = slot.read() {
187 v.extend(roots.iter().cloned());
188 }
189 }
190 v
191 }
192}
193
194#[async_trait]
195impl Tool for ReadFile {
196 fn spec(&self) -> ToolSpec {
197 ToolSpec {
198 name: "Read".into(),
199 description:
200 "Read a UTF-8 text file under the workspace. Optionally return a line range."
201 .to_string(),
202 parameters: json!({
203 "type": "object",
204 "properties": {
205 "path": {"type": "string", "description": "Path relative to the workspace root"},
206 "start_line": {"type": "integer", "description": "Optional 1-indexed inclusive start line. If end_line is set but not start_line, defaults to 1."},
207 "end_line": {"type": "integer", "description": "Optional 1-indexed inclusive end line. If start_line is set but not end_line, defaults to last line."}
208 },
209 "required": ["path"]
210 }),
211 }
212 }
213
214 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
215 crate::tools::ToolSideEffect::ReadOnly
216 }
217
218 fn kind(&self) -> ToolKind {
219 ToolKind::Read
220 }
221
222 async fn execute(&self, args: Value) -> Result<String> {
223 let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
224 name: "Read".into(),
225 message: "missing `path`".into(),
226 })?;
227 let abs = resolve_within_any(&self.all_roots(), path, false)?;
228 let bytes = tokio::fs::read(&abs).await.map_err(|e| Error::Tool {
229 name: "Read".into(),
230 call_id: None,
231 message: format!("{}: {e}", abs.display()),
232 })?;
233 if bytes.len() > self.max_bytes {
234 return Err(Error::Tool {
235 name: "Read".into(),
236 call_id: None,
237 message: format!(
238 "file too large: {} bytes (max {})",
239 bytes.len(),
240 self.max_bytes
241 ),
242 });
243 }
244 let content = String::from_utf8(bytes).map_err(|e| Error::Tool {
245 name: "Read".into(),
246 call_id: None,
247 message: format!("not utf-8: {e}"),
248 })?;
249
250 let mtime = get_file_mtime(&abs);
252
253 let start_line = args["start_line"].as_u64();
255 let end_line = args["end_line"].as_u64();
256
257 if start_line.is_none() && end_line.is_none() {
259 if let Some(slot) = &self.read_state {
260 if let Ok(mut state) = slot.lock() {
261 state.record(abs.clone(), false, content.clone(), mtime);
262 }
263 }
264 return Ok(content);
265 }
266
267 let total_lines = content.lines().count();
269 if total_lines == 0 {
270 if let Some(slot) = &self.read_state {
272 if let Ok(mut state) = slot.lock() {
273 state.record(abs.clone(), false, content.clone(), mtime);
274 }
275 }
276 return Ok(content);
277 }
278
279 let start = match start_line {
281 Some(0) => {
282 return Err(Error::BadToolArgs {
283 name: "Read".to_string(),
284 message: "start_line must be >= 1 (1-indexed)".to_string(),
285 });
286 }
287 Some(n) => n as usize,
288 None => 1,
289 };
290
291 let end = match end_line {
292 Some(0) => {
293 return Err(Error::BadToolArgs {
294 name: "Read".to_string(),
295 message: "end_line must be >= 1 (1-indexed)".to_string(),
296 });
297 }
298 Some(n) => n as usize,
299 None => total_lines,
300 };
301
302 if start > end {
304 return Err(Error::BadToolArgs {
305 name: "Read".to_string(),
306 message: format!("start_line ({}) must be <= end_line ({})", start, end),
307 });
308 }
309
310 let start = start.min(total_lines);
312 let end = end.min(total_lines);
313
314 if start_line.is_some() && start > total_lines {
316 return Err(Error::BadToolArgs {
317 name: "Read".to_string(),
318 message: format!("start_line {} exceeds total lines {}", start, total_lines),
319 });
320 }
321
322 let is_partial = !(start == 1 && end == total_lines);
324 if let Some(slot) = &self.read_state {
325 if let Ok(mut state) = slot.lock() {
326 state.record(abs.clone(), is_partial, content.clone(), mtime);
327 }
328 }
329
330 let slice: String = content
332 .lines()
333 .skip(start - 1)
334 .take(end - start + 1)
335 .collect::<Vec<_>>()
336 .join("\n");
337
338 Ok(format!(
339 "# range: lines {}-{} of {}\n{}",
340 start, end, total_lines, slice
341 ))
342 }
343}
344
345#[derive(Debug, Clone)]
346pub struct WriteFile {
347 pub root: PathBuf,
348 pub extra_roots: Vec<(PathBuf, AccessTier)>,
349 pub session_roots: Option<SharedSandboxRoots>,
350 pub read_state: Option<Arc<Mutex<ReadFileState>>>,
354}
355
356impl WriteFile {
357 pub fn new(root: impl Into<PathBuf>) -> Self {
358 Self {
359 root: root.into(),
360 extra_roots: Vec::new(),
361 session_roots: None,
362 read_state: None,
363 }
364 }
365
366 pub fn with_read_state(mut self, slot: Arc<Mutex<ReadFileState>>) -> Self {
367 self.read_state = Some(slot);
368 self
369 }
370
371 pub fn with_extra_roots(
373 mut self,
374 extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
375 ) -> Self {
376 self.extra_roots.extend(extra);
377 self
378 }
379
380 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
382 self.session_roots = Some(slot);
383 self
384 }
385
386 pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
389 if let Some(s) = slot {
390 self.session_roots = Some(s);
391 }
392 self
393 }
394
395 fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
396 let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
397 v.push((self.root.clone(), AccessTier::ReadWrite));
398 v.extend(self.extra_roots.iter().cloned());
399 if let Some(slot) = &self.session_roots {
400 if let Ok(roots) = slot.read() {
401 v.extend(roots.iter().cloned());
402 }
403 }
404 v
405 }
406}
407
408#[async_trait]
409impl Tool for WriteFile {
410 fn spec(&self) -> ToolSpec {
411 ToolSpec {
412 name: "Write".into(),
413 description: "Write/overwrite a UTF-8 text file under the workspace. \
414 Parent directories are created.\n\
415 If the file already exists, you MUST read it first via the \
416 `Read` tool. This tool will error if you did not read an \
417 existing file before writing to it."
418 .into(),
419 parameters: json!({
420 "type": "object",
421 "properties": {
422 "path": {"type": "string", "description": "Path relative to the workspace root"},
423 "contents": {"type": "string", "description": "Full new contents of the file"}
424 },
425 "required": ["path", "contents"]
426 }),
427 }
428 }
429
430 fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
431 crate::tools::ToolSideEffect::Mutating
432 }
433
434 fn kind(&self) -> ToolKind {
435 ToolKind::Write
436 }
437
438 async fn execute(&self, args: Value) -> Result<String> {
439 let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
440 name: "Write".into(),
441 message: "missing `path`".into(),
442 })?;
443 let contents = args["contents"]
444 .as_str()
445 .ok_or_else(|| Error::BadToolArgs {
446 name: "Write".into(),
447 message: "missing `contents`".into(),
448 })?;
449 let abs = resolve_within_any(&self.all_roots(), path, true)?;
450
451 let file_exists = abs.exists();
457 if file_exists {
458 if let Some(slot) = &self.read_state {
459 let staleness_check: Option<(u64, String)> = {
460 let state = slot.lock().map_err(|_| Error::Tool {
461 name: "Write".into(),
462 call_id: None,
463 message: "internal: read-state lock poisoned".into(),
464 })?;
465 match state.get(&abs) {
466 None => {
467 return Err(Error::Tool {
468 name: "Write".into(),
469 call_id: None,
470 message: format!(
471 "File `{path}` has not been read yet. \
472 Read it first before writing to it."
473 ),
474 });
475 }
476 Some(record) if record.is_partial => {
484 return Err(Error::Tool {
485 name: "Write".into(),
486 call_id: None,
487 message: format!(
488 "File `{path}` was only partially read \
489 (line range). Read the complete file before writing."
490 ),
491 });
492 }
493 Some(record) => {
494 let disk_mtime = get_file_mtime(&abs);
495 if disk_mtime > record.timestamp {
496 Some((disk_mtime, record.content.clone()))
497 } else {
498 None
499 }
500 }
501 }
502 };
504
505 if let Some((_disk_mtime, cached_content)) = staleness_check {
507 let disk_content = tokio::fs::read_to_string(&abs).await.unwrap_or_default();
508 if disk_content != cached_content {
509 return Err(Error::Tool {
510 name: "Write".into(),
511 call_id: None,
512 message: format!(
513 "File `{path}` has been modified since it was \
514 last read. Read it again before writing."
515 ),
516 });
517 }
518 }
520 }
521 }
522
523 if let Some(parent) = abs.parent() {
524 tokio::fs::create_dir_all(parent)
525 .await
526 .map_err(|e| Error::Tool {
527 name: "Write".into(),
528 call_id: None,
529 message: format!("mkdir {}: {e}", parent.display()),
530 })?;
531 }
532
533 if file_exists {
539 if let Some(slot) = &self.read_state {
540 let cached: Option<(u64, String)> = {
541 let state = slot.lock().map_err(|_| Error::Tool {
542 name: "Write".into(),
543 call_id: None,
544 message: "internal: read-state lock poisoned".into(),
545 })?;
546 state.get(&abs).map(|r| (r.timestamp, r.content.clone()))
547 };
549 if let Some((cached_ts, cached_content)) = cached {
550 let disk_mtime = get_file_mtime(&abs);
551 if disk_mtime > cached_ts {
552 let disk_content =
553 tokio::fs::read_to_string(&abs).await.unwrap_or_default();
554 if disk_content != cached_content {
555 return Err(Error::Tool {
556 name: "Write".into(),
557 call_id: None,
558 message: format!(
559 "File `{path}` was modified after it was last \
560 read. Read it again before writing."
561 ),
562 });
563 }
564 }
565 }
566 }
567 }
568
569 tokio::fs::write(&abs, contents)
570 .await
571 .map_err(|e| Error::Tool {
572 name: "Write".into(),
573 call_id: None,
574 message: format!("{}: {e}", abs.display()),
575 })?;
576
577 if let Some(slot) = &self.read_state {
580 if let Ok(mut state) = slot.lock() {
581 let new_mtime = get_file_mtime(&abs);
582 state.record(abs, false, contents.to_string(), new_mtime);
583 }
584 }
585
586 Ok(format!("wrote {} bytes to {}", contents.len(), path))
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593 use tempfile::TempDir;
594
595 #[tokio::test]
596 async fn write_then_read_roundtrip() {
597 let tmp = TempDir::new().unwrap();
598 let w = WriteFile::new(tmp.path());
599 let r = ReadFile::new(tmp.path());
600 w.execute(json!({"path":"hello.txt","contents":"world"}))
601 .await
602 .unwrap();
603 let got = r.execute(json!({"path":"hello.txt"})).await.unwrap();
604 assert_eq!(got, "world");
605 }
606
607 #[tokio::test]
608 async fn write_creates_parent_dirs() {
609 let tmp = TempDir::new().unwrap();
610 WriteFile::new(tmp.path())
611 .execute(json!({"path":"a/b/c.txt","contents":"x"}))
612 .await
613 .unwrap();
614 assert!(tmp.path().join("a/b/c.txt").exists());
615 }
616
617 #[tokio::test]
618 async fn rejects_escape() {
619 let tmp = TempDir::new().unwrap();
620 let r = ReadFile::new(tmp.path());
621 let err = r
622 .execute(json!({"path":"../etc/passwd"}))
623 .await
624 .unwrap_err();
625 assert!(matches!(err, Error::BadToolArgs { .. }));
626 }
627 #[tokio::test]
629 async fn read_file_with_line_range() {
630 let tmp = TempDir::new().unwrap();
631 std::fs::write(
632 tmp.path().join("test.txt"),
633 "line1
634line2
635line3
636line4
637line5
638",
639 )
640 .unwrap();
641 let r = ReadFile::new(tmp.path());
642 let got = r
643 .execute(json!({"path":"test.txt", "start_line": 2, "end_line": 3}))
644 .await
645 .unwrap();
646 assert!(got.starts_with(
648 "# range: lines 2-3 of 5
649"
650 ));
651 assert!(got.contains("line2"));
652 assert!(got.contains("line3"));
653 assert!(!got.contains("line1"));
654 assert!(!got.contains("line4"));
655 assert!(!got.contains("line5"));
656 }
657
658 #[tokio::test]
659 async fn read_file_without_range_returns_full() {
660 let tmp = TempDir::new().unwrap();
661 std::fs::write(
662 tmp.path().join("test.txt"),
663 "line1
664line2
665line3",
666 )
667 .unwrap();
668 let r = ReadFile::new(tmp.path());
669 let got = r.execute(json!({"path":"test.txt"})).await.unwrap();
670 assert!(!got.starts_with("# range:"));
672 assert_eq!(
673 got,
674 "line1
675line2
676line3"
677 );
678 }
679
680 #[tokio::test]
681 async fn read_file_invalid_range_start_greater_than_end() {
682 let tmp = TempDir::new().unwrap();
683 std::fs::write(
684 tmp.path().join("test.txt"),
685 "line1
686line2
687line3
688",
689 )
690 .unwrap();
691 let r = ReadFile::new(tmp.path());
692 let err = r
693 .execute(json!({"path":"test.txt", "start_line": 10, "end_line": 5}))
694 .await
695 .unwrap_err();
696 assert!(matches!(err, Error::BadToolArgs { .. }));
697 let err_msg = format!("{:?}", err);
698 assert!(err_msg.contains("start_line") && err_msg.contains("end_line"));
699 }
700
701 #[tokio::test]
704 async fn read_state_records_full_read() {
705 let tmp = TempDir::new().unwrap();
706 std::fs::write(tmp.path().join("f.txt"), "line1\nline2\nline3\n").unwrap();
707 let slot = Arc::new(Mutex::new(ReadFileState::new()));
708 let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
709 r.execute(json!({"path": "f.txt"})).await.unwrap();
710 let state = slot.lock().unwrap();
711 let rec = state
712 .get(&tmp.path().join("f.txt"))
713 .expect("should be recorded");
714 assert!(!rec.is_partial, "full read must be is_partial=false");
715 }
716
717 #[tokio::test]
718 async fn read_state_records_partial_read() {
719 let tmp = TempDir::new().unwrap();
720 std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n").unwrap();
721 let slot = Arc::new(Mutex::new(ReadFileState::new()));
722 let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
723 r.execute(json!({"path": "f.txt", "start_line": 2, "end_line": 5}))
724 .await
725 .unwrap();
726 let state = slot.lock().unwrap();
727 let rec = state
728 .get(&tmp.path().join("f.txt"))
729 .expect("should be recorded");
730 assert!(rec.is_partial, "line-range read must be is_partial=true");
731 }
732
733 #[tokio::test]
734 async fn read_state_full_range_not_partial() {
735 let tmp = TempDir::new().unwrap();
736 std::fs::write(tmp.path().join("f.txt"), "a\nb\nc\nd\ne\n").unwrap();
737 let slot = Arc::new(Mutex::new(ReadFileState::new()));
738 let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
739 r.execute(json!({"path": "f.txt", "start_line": 1, "end_line": 5}))
741 .await
742 .unwrap();
743 let state = slot.lock().unwrap();
744 let rec = state
745 .get(&tmp.path().join("f.txt"))
746 .expect("should be recorded");
747 assert!(!rec.is_partial, "start=1 end=N must be is_partial=false");
748 }
749
750 #[tokio::test]
753 async fn read_file_from_extra_root() {
754 use crate::tools::AccessTier;
755 let ws = TempDir::new().unwrap();
756 let extra = TempDir::new().unwrap();
757 std::fs::write(extra.path().join("outside.txt"), "hello-from-extra").unwrap();
758 let r = ReadFile::new(ws.path())
759 .with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadOnly)]);
760 let abs = extra.path().join("outside.txt");
761 let got = r
762 .execute(json!({"path": abs.to_string_lossy()}))
763 .await
764 .unwrap();
765 assert_eq!(got, "hello-from-extra");
766 }
767
768 #[tokio::test]
769 async fn write_file_blocked_on_readonly_extra_root() {
770 use crate::tools::AccessTier;
771 let ws = TempDir::new().unwrap();
772 let extra = TempDir::new().unwrap();
773 let w = WriteFile::new(ws.path())
774 .with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadOnly)]);
775 let abs = extra.path().join("new.txt");
776 let err = w
777 .execute(json!({"path": abs.to_string_lossy(), "contents": "x"}))
778 .await
779 .unwrap_err();
780 assert!(
781 err.to_string().contains("read-only"),
782 "write to read-only extra root must be rejected: {err}"
783 );
784 }
785
786 #[tokio::test]
787 async fn write_file_allowed_on_readwrite_extra_root() {
788 use crate::tools::AccessTier;
789 let ws = TempDir::new().unwrap();
790 let extra = TempDir::new().unwrap();
791 let w = WriteFile::new(ws.path())
792 .with_extra_roots(vec![(extra.path().to_path_buf(), AccessTier::ReadWrite)]);
793 let abs = extra.path().join("new.txt");
794 w.execute(json!({"path": abs.to_string_lossy(), "contents": "x"}))
795 .await
796 .unwrap();
797 assert!(abs.exists());
798 }
799
800 #[tokio::test]
809 async fn read_file_exactly_max_bytes_succeeds() {
810 let tmp = TempDir::new().unwrap();
811 let max = 256 * 1024; let content = "x".repeat(max);
813 std::fs::write(tmp.path().join("big.txt"), content.as_bytes()).unwrap();
814 let r = ReadFile::new(tmp.path());
815 let got = r.execute(json!({"path": "big.txt"})).await.unwrap();
816 assert_eq!(got.len(), max, "file at exactly max_bytes must succeed");
817 }
818
819 #[tokio::test]
821 async fn read_file_one_over_max_bytes_fails() {
822 let tmp = TempDir::new().unwrap();
823 let content = "x".repeat(256 * 1024 + 1);
824 std::fs::write(tmp.path().join("toobig.txt"), content.as_bytes()).unwrap();
825 let r = ReadFile::new(tmp.path());
826 let err = r.execute(json!({"path": "toobig.txt"})).await.unwrap_err();
827 assert!(
828 matches!(err, Error::Tool { .. }),
829 "one byte over max_bytes must return Tool error: {err:?}"
830 );
831 }
832
833 #[tokio::test]
843 async fn read_file_start_only_returns_range_from_that_line() {
844 let tmp = TempDir::new().unwrap();
845 std::fs::write(
846 tmp.path().join("f.txt"),
847 "FIRST\nSECOND\nTHIRD\nFOURTH\nFIFTH",
848 )
849 .unwrap();
850 let r = ReadFile::new(tmp.path());
851 let got = r
852 .execute(json!({"path": "f.txt", "start_line": 3}))
853 .await
854 .unwrap();
855 assert!(
856 got.starts_with("# range:"),
857 "start_line-only read must produce a range header; got: {got}"
858 );
859 assert!(got.contains("THIRD"), "line 3 must be present");
860 assert!(got.contains("FOURTH"), "line 4 must be present");
861 assert!(got.contains("FIFTH"), "line 5 must be present");
862 assert!(!got.contains("FIRST"), "line 1 must be absent");
863 assert!(!got.contains("SECOND"), "line 2 must be absent");
864 }
865
866 #[tokio::test]
873 async fn read_file_single_line_range_succeeds() {
874 let tmp = TempDir::new().unwrap();
875 std::fs::write(tmp.path().join("f.txt"), "alpha\nbeta\ngamma\ndelta\n").unwrap();
876 let r = ReadFile::new(tmp.path());
877 let got = r
878 .execute(json!({"path": "f.txt", "start_line": 2, "end_line": 2}))
879 .await
880 .unwrap();
881 assert!(got.contains("beta"), "line 2 must be present");
882 assert!(!got.contains("alpha"), "line 1 must be absent");
883 assert!(!got.contains("gamma"), "line 3 must be absent");
884 }
885
886 #[tokio::test]
894 async fn read_file_last_line_succeeds() {
895 let tmp = TempDir::new().unwrap();
896 std::fs::write(tmp.path().join("f.txt"), "first\nsecond\nthird").unwrap();
897 let r = ReadFile::new(tmp.path());
898 let got = r
899 .execute(json!({"path": "f.txt", "start_line": 3, "end_line": 3}))
900 .await
901 .unwrap();
902 assert!(got.contains("third"), "last line must be returned");
903 assert!(!got.contains("first"), "line 1 must be absent");
904 assert!(!got.contains("second"), "line 2 must be absent");
905 }
906
907 #[tokio::test]
916 async fn read_state_partial_from_line_one_not_reaching_end() {
917 let tmp = TempDir::new().unwrap();
918 std::fs::write(tmp.path().join("f.txt"), "1\n2\n3\n4\n5\n").unwrap();
919 let slot = Arc::new(Mutex::new(ReadFileState::new()));
920 let r = ReadFile::new(tmp.path()).with_read_state(slot.clone());
921 r.execute(json!({"path": "f.txt", "start_line": 1, "end_line": 3}))
923 .await
924 .unwrap();
925 let state = slot.lock().unwrap();
926 let rec = state
927 .get(&tmp.path().join("f.txt"))
928 .expect("must be recorded");
929 assert!(
930 rec.is_partial,
931 "lines 1-3 of 5 must be recorded as partial (is_partial=true)"
932 );
933 }
934
935 #[tokio::test]
941 async fn read_file_start_line_zero_is_invalid() {
942 let tmp = TempDir::new().unwrap();
944 std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
945 let r = ReadFile::new(tmp.path());
946 let err = r
947 .execute(json!({"path": "f.txt", "start_line": 0}))
948 .await
949 .unwrap_err();
950 assert!(
951 matches!(err, Error::BadToolArgs { .. }),
952 "start_line=0 must return BadToolArgs"
953 );
954 }
955
956 #[tokio::test]
957 async fn read_file_end_line_zero_is_invalid() {
958 let tmp = TempDir::new().unwrap();
960 std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
961 let r = ReadFile::new(tmp.path());
962 let err = r
963 .execute(json!({"path": "f.txt", "end_line": 0}))
964 .await
965 .unwrap_err();
966 assert!(
967 matches!(err, Error::BadToolArgs { .. }),
968 "end_line=0 must return BadToolArgs"
969 );
970 }
971
972 #[tokio::test]
973 async fn read_file_start_line_beyond_total_is_invalid() {
974 let tmp = TempDir::new().unwrap();
976 std::fs::write(tmp.path().join("f.txt"), "one\ntwo\n").unwrap();
977 let r = ReadFile::new(tmp.path());
978 let err = r
979 .execute(json!({"path": "f.txt", "start_line": 999}))
980 .await
981 .unwrap_err();
982 assert!(
983 matches!(err, Error::BadToolArgs { .. }),
984 "start_line beyond file length must return BadToolArgs"
985 );
986 }
987
988 #[tokio::test]
992 async fn read_file_range_includes_all_lines_through_end() {
993 let tmp = TempDir::new().unwrap();
994 std::fs::write(tmp.path().join("f.txt"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
995 let r = ReadFile::new(tmp.path());
996 let got = r
997 .execute(json!({"path": "f.txt", "start_line": 2, "end_line": 5}))
998 .await
999 .unwrap();
1000 assert!(got.contains("two"), "line 2 must be present");
1001 assert!(got.contains("three"), "line 3 must be present");
1002 assert!(got.contains("four"), "line 4 must be present");
1003 assert!(got.contains("five"), "line 5 must be present");
1004 assert!(!got.contains("one"), "line 1 must be absent");
1005 }
1006
1007 fn bump_mtime_future(path: &Path) {
1013 use std::time::{Duration, SystemTime};
1014 let future = SystemTime::now() + Duration::from_secs(3600);
1015 let f = std::fs::OpenOptions::new()
1016 .write(true)
1017 .open(path)
1018 .expect("open for set_modified");
1019 f.set_modified(future).expect("set_modified");
1020 }
1021
1022 #[tokio::test]
1023 async fn write_new_file_allowed_without_prior_read() {
1024 let tmp = TempDir::new().unwrap();
1026 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1027 let w = WriteFile::new(tmp.path()).with_read_state(slot);
1028 w.execute(json!({"path": "fresh.txt", "contents": "hi"}))
1029 .await
1030 .expect("new file write must succeed without prior Read");
1031 assert_eq!(
1032 std::fs::read_to_string(tmp.path().join("fresh.txt")).unwrap(),
1033 "hi"
1034 );
1035 }
1036
1037 #[tokio::test]
1038 async fn write_existing_file_rejected_without_prior_read() {
1039 let tmp = TempDir::new().unwrap();
1040 let path = tmp.path().join("e.txt");
1041 std::fs::write(&path, "orig").unwrap();
1042 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1043 let w = WriteFile::new(tmp.path()).with_read_state(slot);
1044 let err = w
1045 .execute(json!({"path": "e.txt", "contents": "new"}))
1046 .await
1047 .unwrap_err();
1048 let msg = format!("{err}");
1049 assert!(
1050 msg.contains("has not been read yet"),
1051 "expected 'has not been read yet', got: {msg}"
1052 );
1053 }
1054
1055 #[tokio::test]
1056 async fn write_existing_file_rejected_when_partial_read() {
1057 let tmp = TempDir::new().unwrap();
1058 let path = tmp.path().join("e.txt");
1059 std::fs::write(&path, "line1\nline2\nline3\n").unwrap();
1060 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1061 ReadFile::new(tmp.path())
1063 .with_read_state(slot.clone())
1064 .execute(json!({"path": "e.txt", "start_line": 1, "end_line": 2}))
1065 .await
1066 .unwrap();
1067 let err = WriteFile::new(tmp.path())
1068 .with_read_state(slot)
1069 .execute(json!({"path": "e.txt", "contents": "new"}))
1070 .await
1071 .unwrap_err();
1072 let msg = format!("{err}");
1073 assert!(
1074 msg.contains("partially read"),
1075 "expected 'partially read', got: {msg}"
1076 );
1077 }
1078
1079 #[tokio::test]
1080 async fn write_succeeds_after_full_read() {
1081 let tmp = TempDir::new().unwrap();
1082 let path = tmp.path().join("e.txt");
1083 std::fs::write(&path, "orig").unwrap();
1084 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1085 ReadFile::new(tmp.path())
1086 .with_read_state(slot.clone())
1087 .execute(json!({"path": "e.txt"}))
1088 .await
1089 .unwrap();
1090 WriteFile::new(tmp.path())
1091 .with_read_state(slot)
1092 .execute(json!({"path": "e.txt", "contents": "rewritten"}))
1093 .await
1094 .expect("full read must allow write");
1095 assert_eq!(std::fs::read_to_string(&path).unwrap(), "rewritten");
1096 }
1097
1098 #[tokio::test]
1099 async fn write_rejected_when_file_modified_since_read() {
1100 let tmp = TempDir::new().unwrap();
1101 let path = tmp.path().join("e.txt");
1102 std::fs::write(&path, "orig").unwrap();
1103 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1104 ReadFile::new(tmp.path())
1105 .with_read_state(slot.clone())
1106 .execute(json!({"path": "e.txt"}))
1107 .await
1108 .unwrap();
1109 std::fs::write(&path, "externally changed").unwrap();
1111 bump_mtime_future(&path);
1112 let err = WriteFile::new(tmp.path())
1113 .with_read_state(slot)
1114 .execute(json!({"path": "e.txt", "contents": "new"}))
1115 .await
1116 .unwrap_err();
1117 let msg = format!("{err}");
1118 assert!(
1119 msg.contains("modified") && msg.contains("Read it again"),
1120 "expected staleness rejection, got: {msg}"
1121 );
1122 }
1123
1124 #[tokio::test]
1125 async fn write_post_update_cache_allows_consecutive_write() {
1126 let tmp = TempDir::new().unwrap();
1129 let path = tmp.path().join("e.txt");
1130 std::fs::write(&path, "orig").unwrap();
1131 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1132 ReadFile::new(tmp.path())
1133 .with_read_state(slot.clone())
1134 .execute(json!({"path": "e.txt"}))
1135 .await
1136 .unwrap();
1137 let w = WriteFile::new(tmp.path()).with_read_state(slot);
1138 w.execute(json!({"path": "e.txt", "contents": "v1"}))
1139 .await
1140 .unwrap();
1141 w.execute(json!({"path": "e.txt", "contents": "v2"}))
1143 .await
1144 .expect("consecutive write must succeed without re-read");
1145 assert_eq!(std::fs::read_to_string(&path).unwrap(), "v2");
1146 }
1147
1148 #[tokio::test]
1149 async fn write_stale_mtime_but_unchanged_content_allowed() {
1150 let tmp = TempDir::new().unwrap();
1153 let path = tmp.path().join("e.txt");
1154 std::fs::write(&path, "same").unwrap();
1155 let slot = Arc::new(Mutex::new(ReadFileState::new()));
1156 ReadFile::new(tmp.path())
1157 .with_read_state(slot.clone())
1158 .execute(json!({"path": "e.txt"}))
1159 .await
1160 .unwrap();
1161 bump_mtime_future(&path);
1163 WriteFile::new(tmp.path())
1164 .with_read_state(slot)
1165 .execute(json!({"path": "e.txt", "contents": "new"}))
1166 .await
1167 .expect("unchanged content with bumped mtime must be allowed");
1168 }
1169}