Skip to main content

recursive/tools/
fs.rs

1//! Filesystem tools: `Read`, `Write`.
2//!
3//! All paths are sandboxed to a workspace root. Reads/writes outside the
4//! root are rejected at the tool layer, so the model can't (accidentally
5//! or otherwise) touch the rest of the disk.
6
7use 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
18// ---------------------------------------------------------------------------
19// ReadFileState — shared between ReadFile, EditTool, and WriteFile
20// ---------------------------------------------------------------------------
21
22/// Maximum number of entries in the read-state cache. When the cache exceeds
23/// this limit, the oldest entry (by insertion order) is evicted. Aligned with
24/// fake-cc's `READ_FILE_STATE_CACHE_SIZE` (100).
25const READ_FILE_STATE_MAX_ENTRIES: usize = 100;
26
27/// Per-file read record written by `ReadFile` and consumed by `EditTool` /
28/// `WriteFile`.
29#[derive(Debug, Clone)]
30pub struct ReadRecord {
31    /// True when the read used start_line/end_line and did NOT cover the whole
32    /// file. A read with start_line=1 and end_line=total_lines is a full read.
33    pub is_partial: bool,
34    /// Cached file content at read time (CRLF → LF normalised). Used for
35    /// staleness-check content fallback (mtime changed but content identical).
36    pub content: String,
37    /// File modification timestamp (`mtime` epoch millis) at read time.
38    /// Compared against the on-disk mtime before allowing edits.
39    pub timestamp: u64,
40}
41
42/// Session-scoped state tracking which files have been read (and whether
43/// those reads were partial). Injected via `Arc<Mutex<ReadFileState>>` into
44/// `ReadFile`, `EditTool`, and `WriteFile`.
45#[derive(Debug, Default, Clone)]
46pub struct ReadFileState {
47    records: HashMap<PathBuf, ReadRecord>,
48    /// Insertion order for LRU-style eviction.
49    insertion_order: Vec<PathBuf>,
50}
51
52impl ReadFileState {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Record a read (or post-edit/post-write update). Evicts the oldest
58    /// entry when the cache exceeds [`READ_FILE_STATE_MAX_ENTRIES`].
59    pub fn record(&mut self, path: PathBuf, is_partial: bool, content: String, timestamp: u64) {
60        // Remove old insertion-order entry if it exists (re-insert at tail).
61        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        // Evict oldest entries when over capacity.
75        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    /// Return up to `n` most-recently-recorded files, newest first, as
88    /// (path, content) pairs. Locks the mutex externally; this method takes
89    /// `&self` and reads `insertion_order`/`records` without mutating.
90    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
100// ---------------------------------------------------------------------------
101// File mtime helper
102// ---------------------------------------------------------------------------
103
104/// Return the file's modification time as epoch milliseconds (floored).
105/// Returns 0 if the file does not exist or the metadata cannot be read.
106pub(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// ---------------------------------------------------------------------------
116// ReadFile
117// ---------------------------------------------------------------------------
118
119#[derive(Debug, Clone)]
120pub struct ReadFile {
121    pub root: PathBuf,
122    /// Additional sandbox roots beyond the primary workspace, each tagged
123    /// with an access tier. The primary `root` is always
124    /// [`AccessTier::ReadWrite`] and is prepended to this list at resolution
125    /// time so the agent can read files in declared extra directories.
126    pub extra_roots: Vec<(PathBuf, AccessTier)>,
127    /// Session-scoped, runtime-mutable roots (e.g. added via the TUI
128    /// `/add-dir` command). Consulted on every call so newly added roots
129    /// take effect immediately.
130    pub session_roots: Option<SharedSandboxRoots>,
131    pub max_bytes: usize,
132    /// Optional shared state slot. When `Some`, every successful read is
133    /// recorded so `EditTool` can enforce the partial-read guard.
134    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    /// Append additional allowed sandbox roots (e.g. from `[sandbox]
154    /// extra_dirs`). The primary workspace root is always prepended at
155    /// resolution time, so callers only need to pass the *extra* ones here.
156    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    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
165    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
166        self.session_roots = Some(slot);
167        self
168    }
169
170    /// Convenience: attach the shared slot only when `Some`. Used by
171    /// [`crate::tools::build_standard_tools_with_roots`] so headless/CLI
172    /// builds can pass `None` without conditional chaining at every call site.
173    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    /// All roots, primary first, as consumed by [`resolve_within_any`].
181    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        // Capture mtime right after reading so staleness checks are accurate.
251        let mtime = get_file_mtime(&abs);
252
253        // Parse optional line range parameters
254        let start_line = args["start_line"].as_u64();
255        let end_line = args["end_line"].as_u64();
256
257        // If no range specified, this is a full read.
258        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        // Count total lines
268        let total_lines = content.lines().count();
269        if total_lines == 0 {
270            // Empty file — record as full read and return as-is.
271            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        // Validate and clamp line numbers (1-indexed)
280        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        // Validate start <= end
303        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        // Clamp to valid range
311        let start = start.min(total_lines);
312        let end = end.min(total_lines);
313
314        // Check if start exceeds total lines
315        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        // A range covering the entire file counts as a full read.
323        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        // Extract the requested slice (1-indexed, inclusive)
331        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    /// Optional shared state slot. When `Some`, writing to an *existing* file
351    /// requires a prior full `Read` and checks staleness (file not modified
352    /// since last read). New files (non-existent) are exempt.
353    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    /// Append additional allowed sandbox roots. See [`ReadFile::with_extra_roots`].
372    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    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
381    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
382        self.session_roots = Some(slot);
383        self
384    }
385
386    /// Convenience: attach the shared slot only when `Some`. See
387    /// [`ReadFile::with_session_roots_opt`].
388    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        // ── Pre-read guard for existing files ──────────────────────────
452        // New files (non-existent on disk) are exempt — no prior Read needed.
453        //
454        // The lock is acquired briefly to extract the record, then dropped
455        // before any `.await` to keep the future `Send`.
456        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                        // Goal 347: Write KEEPS the partial-read reject, even
477                        // though Edit's was relaxed. Write is a whole-file
478                        // overwrite with no `old_string` anchor, so a partial
479                        // read means the model has not seen the bytes it is
480                        // about to clobber. Edit can verify `old_string`
481                        // against disk; Write cannot, so it must require a
482                        // full read.
483                        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                    // MutexGuard dropped here
503                };
504
505                // Async content-fallback staleness check (lock is not held).
506                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                    // Content unchanged despite mtime bump — safe to proceed.
519                }
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        // ── Call-time staleness re-validation ──────────────────────────
534        // The pre-read guard above checked staleness, but `create_dir_all`
535        // has yielded since. Re-probe mtime and, if the file was touched
536        // since the cached read, compare disk content against the cached
537        // content. Mirrors fake-cc's `FileWriteTool.call()` mtime re-check.
538        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                    // guard dropped
548                };
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        // ── Post-write cache update ──────────────────────────────────
578        // Allow subsequent edits/writes without a redundant Read.
579        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    // Tests for line range support (goal-26)
628    #[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        // Should include range header and the sliced content
647        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        // Should NOT have range header when no range specified
671        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    // ── ReadFileState tests ───────────────────────────────────────────────────
702
703    #[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        // start=1 end=5 covers all 5 lines → full read
740        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    // ── extra_roots (sandbox expansion) tests ───────────────────────────────
751
752    #[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    // ── max_bytes boundary (kills 165:24 > with == and > with >=) ───────────
801
802    /// Kills: `replace > with ==` (165:24) and `replace > with >=` (165:24).
803    ///
804    /// The guard is `bytes.len() > max_bytes` (strictly greater-than), so a
805    /// file of exactly `max_bytes` bytes must succeed.
806    /// `> with ==` mutation: fires at exact size → false positive rejection.
807    /// `> with >=` mutation: fires at exact size → false positive rejection.
808    #[tokio::test]
809    async fn read_file_exactly_max_bytes_succeeds() {
810        let tmp = TempDir::new().unwrap();
811        let max = 256 * 1024; // ReadFile::new default
812        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    /// Complementary: one byte over max_bytes must fail.
820    #[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    // ── only start_line given (kills 187:33 && with ||) ──────────────────────
834
835    /// Kills: `replace && with ||` (187:33).
836    ///
837    /// The guard is `start_line.is_none() && end_line.is_none()`.
838    /// With `||`, providing only start_line triggers the early-return and
839    /// returns the full file without a range header.
840    /// This test verifies that supplying start_line alone produces a range
841    /// response (end defaults to total_lines).
842    #[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    // ── single-line range (kills 232:18 > with >=) ────────────────────────────
867
868    /// Kills: `replace > with >=` (232:18).
869    ///
870    /// The guard is `start > end` (rejects only strictly-greater).
871    /// With `>=`, start == end is rejected as invalid, breaking single-line reads.
872    #[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    // ── reading the last line (kills 244:42 > with == and > with >=) ─────────
887
888    /// Kills: `replace > with ==` (244:42) and `replace > with >=` (244:42).
889    ///
890    /// After clamping, start == total_lines when reading the last line.
891    /// Both mutations turn the dead-code guard into a live false-positive that
892    /// rejects reading exactly the last line.
893    #[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    // ── is_partial when starting at line 1 but not reaching end (kills 252:39) ──
908
909    /// Kills: `replace && with ||` (252:39).
910    ///
911    /// `is_partial = !(start == 1 && end == total_lines)`.
912    /// With `||` mutation: `!(start == 1 || end == total_lines)`.
913    /// Reading lines 1..N-1 (start=1 but end < total) is a partial read
914    /// (is_partial=true).  With `||`: `!(true || false)` = false → wrong.
915    #[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        // start=1, end=3 of 5 lines → partial (doesn't reach line 5)
922        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    // ── range content for wider spans (kills 263:23 - with /) ─────────────────
936
937    /// Kills: `replace - with /` (263:23).
938    ///
939    /// `take(end - start + 1)` vs `take(end / start + 1)`.
940    #[tokio::test]
941    async fn read_file_start_line_zero_is_invalid() {
942        // kills `Some(0) =>` guard removal in the start_line match
943        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        // kills `Some(0) =>` guard removal in the end_line match
959        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        // kills `if start_line.is_some() && start > total_lines` guard removal
975        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    /// For start=2, end=3: take(2) in both cases → equivalent.
989    /// For start=2, end=5: original take(4), mutant take(5/2+1)=take(3).
990    /// The 4th line ("four") would be missing with the mutant.
991    #[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    // ── WriteFile pre-read guard (aligned with fake-cc FileWriteTool) ───────
1008
1009    /// Bump a file's mtime to 1 hour in the future so a staleness check based on
1010    /// `disk_mtime > cached_ts` fires deterministically regardless of FS
1011    /// timestamp resolution.
1012    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        // New files (non-existent) are exempt from the pre-read guard.
1025        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        // Partial read via line range.
1062        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        // Externally modify the file content AND bump mtime to the future.
1110        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        // After a successful write, the cache is refreshed so a second write
1127        // to the same file does not require a redundant Read.
1128        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        // No re-read between writes — must succeed via post-write cache update.
1142        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        // Content-fallback: mtime bumped but content identical to cached →
1151        // the write is allowed (Windows cloud-sync / antivirus false positive).
1152        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 without changing content.
1162        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}