Skip to main content

vtcode_core/tools/file_ops/
read.rs

1use super::FileOpsTool;
2use super::is_image_path;
3use super::path_policy::PathSuggestionKind;
4mod legacy;
5mod logging;
6mod segments;
7use crate::config::constants::tools;
8use crate::telemetry::perf::PerfSpan;
9use crate::tools::builder::ToolResponseBuilder;
10use crate::tools::cache::{FILE_CACHE, file_read_cache_config};
11use crate::tools::continuation::{DEFAULT_NEXT_READ_LIMIT, ReadChunkContinuationArgs};
12use crate::tools::handlers::read_file::{ReadFileArgs, ReadFileHandler, ReadFileOutcome};
13use crate::tools::traits::FileTool;
14use crate::tools::types::{Input, PathArgs};
15use anyhow::{Context, Result, anyhow};
16use serde::Deserialize;
17use serde_json::{Value, json};
18use std::path::Path;
19use std::time::UNIX_EPOCH;
20
21pub(crate) const PATCH_READ_CACHE_NONCE: &str = "__vtcode_patch_read_nonce";
22
23const SPOOL_CHUNK_DEFAULT_LIMIT_LINES: usize = DEFAULT_NEXT_READ_LIMIT;
24const SPOOL_CHUNK_MAX_LIMIT_LINES: usize = 50;
25const SPOOL_CHUNK_SENTINEL_MAX_TOKENS: usize = 4096;
26
27#[derive(Clone, Copy, Debug)]
28struct SpoolChunkPlan {
29    offset: usize,
30    limit: usize,
31}
32
33fn is_legacy_read_request(args: &Value, is_image: bool) -> bool {
34    if is_image {
35        return true;
36    }
37
38    if let Some(obj) = args.as_object() {
39        obj.keys().any(|key| {
40            matches!(
41                key.as_str(),
42                "offset_lines"
43                    | "page_size_lines"
44                    | "max_bytes"
45                    | "max_lines"
46                    | "chunk_lines"
47                    | "encoding"
48                    | "max_tokens"
49            )
50        })
51    } else {
52        false
53    }
54}
55
56fn is_new_read_request(args: &Value) -> bool {
57    let new_keys = [
58        "mode",
59        "indentation",
60        "offset",
61        "limit",
62        "o",
63        "l",
64        "offset_bytes",
65        "page_size_bytes",
66        "length",
67    ];
68    new_keys.iter().any(|key| args.get(*key).is_some())
69}
70
71fn looks_like_patch_payload(args: &Value) -> bool {
72    use crate::tools::editing::looks_like_vte_patch;
73
74    args.get("patch").and_then(Value::as_str).is_some_and(looks_like_vte_patch)
75        || args.get("input").and_then(Value::as_str).is_some_and(looks_like_vte_patch)
76        || args.as_str().is_some_and(looks_like_vte_patch)
77}
78
79fn build_read_handler_args(args: &Value, canonical_path: &Path) -> Value {
80    let mut handler_args_json = args.clone();
81    if let Some(obj) = handler_args_json.as_object_mut() {
82        for alias in ["path", "filepath", "target_path", "file"] {
83            obj.remove(alias);
84        }
85        obj.insert("file_path".to_string(), json!(canonical_path.to_string_lossy()));
86
87        if let Some(mode_value) = obj.get("mode").cloned() {
88            let normalized_mode = match mode_value {
89                Value::Null => None,
90                Value::String(raw) => {
91                    let trimmed = raw.trim();
92                    if trimmed.eq_ignore_ascii_case("indentation") {
93                        Some("indentation".to_string())
94                    } else {
95                        Some("slice".to_string())
96                    }
97                }
98                _ => Some("slice".to_string()),
99            };
100
101            if let Some(mode) = normalized_mode {
102                obj.insert("mode".to_string(), json!(mode));
103            } else {
104                obj.remove("mode");
105            }
106        }
107
108        if let Some(indentation_value) = obj.get("indentation").cloned() {
109            match indentation_value {
110                Value::Bool(true) => {
111                    obj.insert("mode".to_string(), json!("indentation"));
112                    obj.insert("indentation".to_string(), json!({}));
113                }
114                Value::Bool(false) | Value::Null => {
115                    obj.remove("indentation");
116                    if obj
117                        .get("mode")
118                        .and_then(Value::as_str)
119                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
120                    {
121                        obj.insert("mode".to_string(), json!("slice"));
122                    }
123                }
124                Value::String(text) if text.eq_ignore_ascii_case("true") => {
125                    obj.insert("mode".to_string(), json!("indentation"));
126                    obj.insert("indentation".to_string(), json!({}));
127                }
128                Value::String(text) if text.eq_ignore_ascii_case("false") => {
129                    obj.remove("indentation");
130                    if obj
131                        .get("mode")
132                        .and_then(Value::as_str)
133                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
134                    {
135                        obj.insert("mode".to_string(), json!("slice"));
136                    }
137                }
138                Value::Object(_) => {
139                    obj.insert("mode".to_string(), json!("indentation"));
140                }
141                _ => {}
142            }
143        }
144
145        if let Some(src) = obj.get("offset_lines").or_else(|| obj.get("o")).cloned() {
146            obj.entry("offset".to_string()).or_insert(src);
147        }
148        if let Some(src) = obj.get("page_size_lines").or_else(|| obj.get("l")).cloned() {
149            obj.entry("limit".to_string()).or_insert(src);
150        }
151
152        // Map `length` alias to `page_size_bytes` for byte-range reads
153        if let Some(src) = obj.get("length").cloned() {
154            obj.entry("page_size_bytes".to_string()).or_insert(src);
155        }
156
157        // Map start_line/end_line to offset/limit when offset is not already set.
158        if let Some(start) = obj.get("start_line").and_then(parse_usize_value) {
159            obj.entry("offset".to_string()).or_insert(json!(start));
160            if let Some(end) = obj.get("end_line").and_then(parse_usize_value)
161                && end >= start
162                && !obj.contains_key("limit")
163            {
164                obj.insert("limit".to_string(), json!(end - start + 1));
165            }
166        }
167
168        if obj.get("offset").and_then(parse_usize_value) == Some(0) {
169            obj.insert("offset".to_string(), json!(1));
170        }
171        if obj.get("limit").and_then(parse_usize_value) == Some(0) {
172            obj.remove("limit");
173        }
174    }
175
176    handler_args_json
177}
178
179/// Locate a verbatim line slice using the selected reader's coordinate system.
180pub(crate) fn source_line_offset(args: &Value) -> Option<usize> {
181    if !is_new_read_request(args) && is_legacy_read_request(args, false) {
182        // Legacy line paging is zero-based; modern slice offsets are one-based.
183        let input: Input = serde_json::from_value(args.clone()).ok()?;
184        return Some(input.offset_lines.unwrap_or(0));
185    }
186    let normalized = build_read_handler_args(args, Path::new("source"));
187    let read: ReadFileArgs = serde_json::from_value(normalized).ok()?;
188    if read.offset_bytes.is_some()
189        || read.page_size_bytes.is_some()
190        || !matches!(read.mode, crate::tools::handlers::read_file::ReadMode::Slice)
191    {
192        return None;
193    }
194    read.offset.checked_sub(1)
195}
196
197/// Admit recovery only for an explicit line range the selected reader honors.
198pub(crate) fn bounded_line_read_path(args: &Value, max_lines: usize) -> Option<String> {
199    let path_args: PathArgs = serde_json::from_value(args.clone()).ok()?;
200    let path = Path::new(&path_args.path);
201    if is_image_path(path) {
202        return None;
203    }
204    if is_new_read_request(args) || !is_legacy_read_request(args, false) {
205        let normalized = build_read_handler_args(args, path);
206        // A missing/zero bound uses the handler default, not a targeted range.
207        normalized.get("limit").and_then(parse_usize_value)?;
208        let read: ReadFileArgs = serde_json::from_value(normalized).ok()?;
209        if !(1..=max_lines).contains(&read.limit)
210            || read.effective_line_limit() > max_lines
211            || read.offset == 0
212            || read.offset_bytes.is_some()
213            || read.page_size_bytes.is_some()
214            || !matches!(read.mode, crate::tools::handlers::read_file::ReadMode::Slice)
215        {
216            return None;
217        }
218    } else {
219        let read: Input = serde_json::from_value(args.clone()).ok()?;
220        let limit = read.page_size_lines?;
221        if !(1..=max_lines).contains(&limit) || read.offset_bytes.is_some() || read.page_size_bytes.is_some() {
222            return None;
223        }
224    }
225    Some(path_args.path)
226}
227
228fn parse_usize_value(value: &Value) -> Option<usize> {
229    value
230        .as_u64()
231        .and_then(|n| usize::try_from(n).ok())
232        .or_else(|| value.as_str().and_then(|s| s.parse::<usize>().ok()))
233}
234
235fn has_explicit_limit(args: &Value) -> bool {
236    ["limit", "l", "page_size_lines", "max_lines", "chunk_lines", "end_line"]
237        .iter()
238        .any(|key| args.get(*key).is_some())
239}
240
241fn has_explicit_offset(args: &Value) -> bool {
242    ["offset", "o", "offset_lines", "offset_bytes", "start_line"]
243        .iter()
244        .any(|key| args.get(*key).is_some())
245}
246
247fn is_full_text_read(args: &Value, is_spool_output: bool) -> bool {
248    !is_spool_output && !has_explicit_offset(args) && !has_explicit_limit(args)
249}
250
251use super::restore_exact_text_content;
252
253fn response_size_bytes(response: &Value) -> Option<u64> {
254    response
255        .get("metadata")
256        .and_then(|metadata| metadata.get("data"))
257        .and_then(|data| data.get("size_bytes"))
258        .and_then(Value::as_u64)
259}
260
261fn apply_spool_chunk_defaults(handler_args_json: &mut Value, raw_args: &Value) -> SpoolChunkPlan {
262    let mut offset = 1usize;
263    let mut limit = SPOOL_CHUNK_DEFAULT_LIMIT_LINES;
264
265    if let Some(obj) = handler_args_json.as_object_mut() {
266        offset = obj.get("offset").and_then(parse_usize_value).unwrap_or(1).max(1);
267
268        let requested_limit = if has_explicit_limit(raw_args) {
269            raw_args
270                .get("limit")
271                .or_else(|| raw_args.get("l"))
272                .or_else(|| raw_args.get("page_size_lines"))
273                .or_else(|| raw_args.get("max_lines"))
274                .or_else(|| raw_args.get("chunk_lines"))
275                .and_then(parse_usize_value)
276                .unwrap_or(SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
277        } else {
278            SPOOL_CHUNK_DEFAULT_LIMIT_LINES
279        };
280
281        limit = requested_limit.clamp(1, SPOOL_CHUNK_MAX_LIMIT_LINES);
282
283        obj.insert("offset".to_string(), json!(offset));
284        obj.insert("limit".to_string(), json!(limit));
285        // Preserve narrow chunking behavior by bypassing MIN_BATCH_LIMIT expansion
286        // in ReadFileHandler::handle (which only applies when max_tokens is absent).
287        obj.entry("max_tokens".to_string())
288            .or_insert_with(|| json!(SPOOL_CHUNK_SENTINEL_MAX_TOKENS));
289    }
290
291    SpoolChunkPlan { offset, limit }
292}
293
294fn path_contains_components(path: &Path, needle: &[&str]) -> bool {
295    // Fail closed on empty needle: vacuous-true would mark every path as
296    // privileged, and `windows(0)` would panic without this guard.
297    if needle.is_empty() {
298        return false;
299    }
300    use std::ffi::OsStr;
301    let components: Vec<&OsStr> = path.components().map(|c| c.as_os_str()).collect();
302    components
303        .windows(needle.len())
304        .any(|window| window.iter().zip(needle.iter()).all(|(hay, ndl)| *hay == OsStr::new(ndl)))
305}
306
307fn is_history_jsonl(path: &Path) -> bool {
308    // Fast path: avoid component iteration if extension doesn't match
309    if path.extension().and_then(|ext| ext.to_str()) != Some("jsonl") {
310        return false;
311    }
312
313    path_contains_components(path, &[".vtcode", "history"])
314}
315
316fn is_tool_output_spool_path(path: &Path) -> bool {
317    crate::tools::SpooledOutputReference::recognizes_path(path)
318}
319
320fn pty_session_id_from_tool_output_path(path: &Path) -> Option<String> {
321    let file_name = path.file_name()?.to_str()?;
322    let session_file = file_name.strip_suffix(".txt")?;
323    let session_id = session_file.strip_prefix("write_stdin_").unwrap_or(session_file);
324    if session_id.starts_with("run-")
325        && session_id.len() > "run-".len()
326        && session_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
327    {
328        Some(session_id.to_string())
329    } else {
330        None
331    }
332}
333
334fn build_history_cache_key(path: &Path, metadata: &std::fs::Metadata, args: &Value) -> Option<String> {
335    let modified = metadata.modified().ok()?;
336    let mtime = modified.duration_since(UNIX_EPOCH).ok()?.as_millis();
337    let size = metadata.len();
338
339    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("legacy");
340    let indentation = args.get("indentation").and_then(Value::as_str).unwrap_or("");
341    let offset = args
342        .get("offset")
343        .or_else(|| args.get("o"))
344        .or_else(|| args.get("offset_lines"))
345        .or_else(|| args.get("offset_bytes"))
346        .and_then(Value::as_u64)
347        .unwrap_or(0);
348    let limit = args
349        .get("limit")
350        .or_else(|| args.get("l"))
351        .or_else(|| args.get("page_size_lines"))
352        .or_else(|| args.get("page_size_bytes"))
353        .and_then(Value::as_u64)
354        .unwrap_or(0);
355    let max_bytes = args.get("max_bytes").and_then(Value::as_u64).unwrap_or(0);
356    let max_tokens = args.get("max_tokens").and_then(Value::as_u64).unwrap_or(0);
357    let encoding = args.get("encoding").and_then(Value::as_str).unwrap_or("");
358
359    Some(format!(
360        "read_file:history:{}:{}:{}:mode={mode}|indent={indentation}|offset={offset}|limit={limit}|max_bytes={max_bytes}|max_tokens={max_tokens}|encoding={encoding}",
361        path.display(),
362        size,
363        mtime
364    ))
365}
366
367impl FileOpsTool {
368    async fn track_read_snapshot(&self, path: &Path) {
369        if let Err(err) = self.edited_file_monitor.track_read(path).await {
370            tracing::warn!(
371                path = %path.display(),
372                error = %err,
373                "Failed to record edited-file read snapshot"
374            );
375        }
376    }
377
378    fn track_read_text_snapshot(&self, path: &Path, content: &str) {
379        if let Err(err) = self.edited_file_monitor.record_read_text(path, content) {
380            tracing::warn!(
381                path = %path.display(),
382                error = %err,
383                "Failed to record edited-file read snapshot"
384            );
385        }
386    }
387
388    async fn track_exact_text_snapshot(&self, path: &Path, content: &str, size_bytes: u64) {
389        if let Some(exact_content) = restore_exact_text_content(content, size_bytes) {
390            self.track_read_text_snapshot(path, &exact_content);
391        } else {
392            self.track_read_snapshot(path).await;
393        }
394    }
395
396    async fn track_cached_read_snapshot(&self, path: &Path, args: &Value, response: &Value) {
397        if is_full_text_read(args, false)
398            && let Some(content) = response.get("content").and_then(Value::as_str)
399            && let Some(size_bytes) = response_size_bytes(response)
400        {
401            self.track_exact_text_snapshot(path, content, size_bytes).await;
402            return;
403        }
404
405        self.track_read_snapshot(path).await;
406    }
407
408    pub async fn read_file(&self, args: Value) -> Result<Value> {
409        let use_cache = args.get(PATCH_READ_CACHE_NONCE).is_none();
410        self.read_file_with_cache(args, use_cache).await
411    }
412
413    async fn read_file_with_cache(&self, args: Value, use_cache: bool) -> Result<Value> {
414        let mut perf = PerfSpan::new("vtcode.perf.read_file_ms");
415
416        let path_args: PathArgs = PathArgs::deserialize(&args).map_err(|_e| {
417            if looks_like_patch_payload(&args) {
418                return anyhow!(
419                    "Error: Patch content was sent to read_file.\n\
420                    Use apply_patch with {{\"patch\":\"...\"}} \
421                    (or {{\"input\":\"...\"}}).\n\
422                    read_file requires a path parameter."
423                );
424            }
425            let received = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
426            anyhow!(
427                "Error: Invalid 'read_file' arguments. Missing required path parameter.\n\
428                Received: {received}\n\
429                Expected: {{\"path\": \"file/path\"}} or {{\"file_path\": \"file/path\"}}\n\
430                Accepted path parameters: path, file_path, filepath, target_path, file, p\n\
431                Optional params: offset_lines, limit, max_bytes, max_tokens"
432            )
433        })?;
434
435        let path_str = &path_args.path;
436
437        // Try to resolve the file path
438        let potential_paths = self.resolve_file_path(path_str).await?;
439        let missing_spool_candidate = potential_paths
440            .iter()
441            .find(|candidate| is_tool_output_spool_path(candidate.as_path()))
442            .cloned();
443        let mut directory_candidate = None;
444
445        for candidate_path in &potential_paths {
446            if !tokio::fs::try_exists(candidate_path).await? {
447                continue;
448            }
449
450            let canonical = self.normalize_and_validate_candidate(candidate_path, path_str).await?;
451
452            if self.should_exclude(&canonical).await {
453                continue;
454            }
455
456            let metadata = tokio::fs::metadata(&canonical).await?;
457            if !metadata.is_file() {
458                if metadata.is_dir() && directory_candidate.is_none() {
459                    directory_candidate = Some(canonical);
460                }
461                continue;
462            }
463
464            let size_bytes = metadata.len();
465            let history_jsonl = is_history_jsonl(&canonical);
466            perf.tag("path_class", if history_jsonl { "history_jsonl" } else { "other" });
467            let is_image = is_image_path(&canonical);
468            let is_spool_output = is_tool_output_spool_path(&canonical);
469            let is_legacy_request = is_legacy_read_request(&args, is_image);
470            let is_new_request = is_new_read_request(&args);
471
472            let cache_config = file_read_cache_config();
473            let cache_key = if use_cache
474                && cache_config.enabled
475                && history_jsonl
476                && size_bytes >= cache_config.min_size_bytes as u64
477                && size_bytes <= cache_config.max_size_bytes as u64
478            {
479                build_history_cache_key(&canonical, &metadata, &args)
480            } else {
481                None
482            };
483
484            if let Some(key) = cache_key.as_ref()
485                && let Some(cached) = FILE_CACHE.get_file(key).await
486            {
487                perf.tag("cache", "hit");
488                self.track_cached_read_snapshot(&canonical, &args, &cached).await;
489                return Ok(cached);
490            }
491            perf.tag("cache", if cache_key.is_some() { "miss" } else { "skip" });
492
493            if !is_image && (is_new_request || !is_legacy_request) {
494                let mut handler_args_json = build_read_handler_args(&args, &canonical);
495                let spool_plan = if is_spool_output {
496                    Some(apply_spool_chunk_defaults(&mut handler_args_json, &args))
497                } else {
498                    None
499                };
500                match serde_json::from_value::<ReadFileArgs>(handler_args_json) {
501                    Ok(read_args) => {
502                        let requested_path = self.workspace_relative_display(&canonical);
503                        let handler = ReadFileHandler;
504                        let requested_offset = read_args.offset;
505                        let ReadFileOutcome {
506                            content,
507                            lines_read: lines_returned,
508                            has_more,
509                            line_truncated,
510                            capped_by_limit,
511                            applied_limit,
512                        } = handler.handle_detailed(read_args).await?;
513                        let full_text_read = is_full_text_read(&args, is_spool_output);
514                        let response_content = if full_text_read {
515                            restore_exact_text_content(&content, size_bytes).unwrap_or_else(|| content.clone())
516                        } else {
517                            content.clone()
518                        };
519
520                        let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
521                            .success()
522                            .message(format!("Successfully read file {requested_path}"))
523                            .content(&response_content)
524                            .field("path", json!(requested_path.clone()))
525                            .field("no_spool", json!(true))
526                            .field("content_kind", json!("text"))
527                            .field("encoding", json!("utf8"))
528                            .data("size_bytes", json!(size_bytes))
529                            .data("content_kind", json!("text"))
530                            .data("encoding", json!("utf8"));
531
532                        if line_truncated {
533                            builder = builder
534                                .field("line_truncated", json!(true))
535                                .field("truncated", json!(true))
536                                .field("truncation_reason", json!("oversized_line"));
537                        }
538
539                        if let Some(plan) = spool_plan {
540                            let next_offset = plan.offset.saturating_add(lines_returned);
541
542                            builder = builder
543                                .field("spool_chunked", json!(true))
544                                .field("lines_returned", json!(lines_returned));
545                            if has_more {
546                                builder = builder.field("has_more", json!(true));
547                                builder = builder.field(
548                                    "next_read_args",
549                                    ReadChunkContinuationArgs::new(requested_path.clone(), next_offset, plan.limit)
550                                        .to_value(),
551                                );
552                                builder = builder.field(
553                                    "next_action",
554                                    json!(
555                                        "Call file read again with the `next_read_args` object above verbatim as arguments to get the next chunk, or narrow with `exec_command.cmd` using `rg` and a specific pattern. Re-reading the same offset returns nothing new and counts toward the per-turn spool chunk cap, which blocks further reads."
556                                    ),
557                                );
558                            }
559                        }
560                        // Absolute-cap continuation. Surface a structured way to
561                        // keep reading after the per-call ceiling was reached.
562                        //
563                        // The condition is `has_more && applied_limit == cap` rather
564                        // than `capped_by_limit && has_more`: on the first (clamped)
565                        // page `applied_limit` equals the cap and `capped_by_limit`
566                        // is true, but on every follow-up page the agent passes back
567                        // `applied_limit` as its `limit`, so `effective_limit` is no
568                        // longer *greater* than the cap and `capped_by_limit` becomes
569                        // false. Using `applied_limit == cap` keeps the pagination
570                        // chain alive on every page that actually hit the ceiling,
571                        // instead of dying after a single hop.
572                        // Byte-range reads set `applied_limit` to 0, so they are
573                        // correctly excluded and use their own `offset_bytes`/
574                        // `page_size_bytes` continuation path.
575                        else if has_more && applied_limit == crate::tools::read_limits::absolute_line_cap() {
576                            let next_offset = requested_offset.saturating_add(lines_returned);
577                            let next_action = if capped_by_limit {
578                                "Read clamped to max_read_lines. \
579                                 Call file read again with `next_read_args` verbatim to get subsequent chunks; \
580                                 do not re-read the same range."
581                            } else {
582                                "Call file read again with `next_read_args` verbatim to get subsequent chunks; \
583                                 do not re-read the same range."
584                            };
585                            builder = builder
586                                .field("has_more", json!(true))
587                                .field("capped_by_limit", json!(capped_by_limit))
588                                .field(
589                                    "next_read_args",
590                                    ReadChunkContinuationArgs::new(requested_path.clone(), next_offset, applied_limit)
591                                        .to_value(),
592                                )
593                                .field("next_action", json!(next_action));
594                        }
595
596                        let response = builder.build_json();
597
598                        if full_text_read {
599                            self.track_exact_text_snapshot(&canonical, &response_content, size_bytes).await;
600                        } else {
601                            self.track_read_snapshot(&canonical).await;
602                        }
603                        if let Some(key) = cache_key.as_ref() {
604                            FILE_CACHE.put_file(key.clone(), response.clone()).await;
605                        }
606
607                        return Ok(response);
608                    }
609                    Err(e) => {
610                        if is_new_request {
611                            return Err(anyhow!(
612                                "Failed to parse arguments for read_file handler: {e}. Args: {args:?}"
613                            ));
614                        }
615                    }
616                }
617            }
618
619            // Legacy path
620            let input: Input = serde_json::from_value(args.clone())
621                .context("Error: Invalid 'read_file' arguments for legacy handler.")?;
622
623            // Check if paging/offset is requested
624            let use_paging = input.offset_bytes.is_some()
625                || input.page_size_bytes.is_some()
626                || input.offset_lines.is_some()
627                || input.page_size_lines.is_some();
628
629            let (content, metadata, truncated, line_truncated) = if use_paging {
630                self.read_file_paged(&canonical, &input).await?
631            } else {
632                let (content, metadata, truncated) = self.read_file_legacy(&canonical, &input).await?;
633                let line_truncated = metadata.get("line_truncated").and_then(Value::as_bool).unwrap_or(false);
634                (content, metadata, truncated, line_truncated)
635            };
636
637            let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
638                .success()
639                .message(format!(
640                    "Successfully read {} bytes from {}",
641                    size_bytes,
642                    self.workspace_relative_display(&canonical)
643                ))
644                .content(&content)
645                .field("path", json!(self.workspace_relative_display(&canonical)))
646                .field("no_spool", json!(true));
647
648            // Merge legacy metadata - extract actual data fields, not wrapper structure
649            if let Some(obj) = metadata.as_object() {
650                for (k, v) in obj {
651                    builder = builder.data(k.clone(), v.clone());
652                }
653            }
654
655            // Special legacy fields at top level
656            if let Some(is_truncated) = metadata.get("is_truncated").and_then(Value::as_bool) {
657                builder = builder.field("is_truncated", json!(is_truncated));
658            }
659            if line_truncated {
660                builder = builder.field("line_truncated", json!(true));
661            }
662            if let Some(encoding) = metadata.get("encoding").and_then(Value::as_str) {
663                builder = builder.field("encoding", json!(encoding));
664            }
665            if let Some(content_kind) = metadata.get("content_kind").and_then(Value::as_str) {
666                builder = builder.field("content_kind", json!(content_kind));
667                if matches!(content_kind, "binary" | "image") {
668                    builder = builder.field("binary", json!(true));
669                }
670            }
671            if let Some(mime_type) = metadata.get("mime_type").and_then(Value::as_str) {
672                builder = builder.field("mime_type", json!(mime_type));
673            }
674
675            // Add paging information
676            if use_paging {
677                if let Some(offset_bytes) = input.offset_bytes {
678                    builder = builder.field("offset_bytes", json!(offset_bytes));
679                }
680                if let Some(page_size_bytes) = input.page_size_bytes {
681                    builder = builder.field("page_size_bytes", json!(page_size_bytes));
682                }
683                if let Some(offset_lines) = input.offset_lines {
684                    builder = builder.field("offset_lines", json!(offset_lines));
685                }
686                if let Some(page_size_lines) = input.page_size_lines {
687                    builder = builder.field("page_size_lines", json!(page_size_lines));
688                }
689                if truncated {
690                    builder = builder.field("truncated", json!(true));
691                    builder = builder.field(
692                        "truncation_reason",
693                        json!(if line_truncated {
694                            "oversized_line"
695                        } else {
696                            "reached_end_of_file"
697                        }),
698                    );
699                }
700            }
701
702            let content_kind = metadata.get("content_kind").and_then(Value::as_str).unwrap_or("text");
703            let full_legacy_text_read = !use_paging && !truncated && content_kind == "text";
704            let response = builder.build_json();
705            if full_legacy_text_read {
706                self.track_exact_text_snapshot(&canonical, &content, size_bytes).await;
707            } else {
708                self.track_read_snapshot(&canonical).await;
709            }
710            if let Some(key) = cache_key.as_ref() {
711                FILE_CACHE.put_file(key.clone(), response.clone()).await;
712            }
713
714            return Ok(response);
715        }
716
717        if let Some(spool_path) = missing_spool_candidate {
718            if let Some(session_id) = pty_session_id_from_tool_output_path(&spool_path) {
719                return Err(anyhow!(
720                    "Error: Session output file not found: {}. This looks like command session output. Use write_stdin with session_id=\"{}\" to poll or continue the active session.",
721                    self.workspace_relative_display(&spool_path),
722                    session_id,
723                ));
724            }
725            return Err(anyhow!(
726                "Error: Spool file not found (possibly expired): {}. Re-run the original tool command to regenerate this output.",
727                self.workspace_relative_display(&spool_path),
728            ));
729        }
730
731        if let Some(directory_path) = directory_candidate {
732            let display_path = self.workspace_relative_display(&directory_path);
733            return Err(anyhow!(
734                "Error: Path '{display_path}' is a directory, not a file. Use `exec_command.cmd` with `find` or `ls` to inspect it.",
735            ));
736        }
737
738        let parent_dir = Path::new(path_str)
739            .parent()
740            .map(|p| p.to_string_lossy().to_string())
741            .unwrap_or_else(|| ".".to_string());
742        let suggestion = self.missing_path_suggestion_suffix(path_str, PathSuggestionKind::File).await;
743        Err(anyhow!(
744            "Error: File not found: {}. Tried paths: {}.{} Use `exec_command.cmd` with `find {}` to discover available files.",
745            path_str,
746            potential_paths
747                .iter()
748                .map(|p| self.workspace_relative_display(p))
749                .collect::<Vec<_>>()
750                .join(", "),
751            suggestion,
752            parent_dir
753        ))
754    }
755}
756
757#[cfg(test)]
758mod read_tests {
759    use super::*;
760    use crate::tools::grep_file::GrepSearchManager;
761    use crate::tools::types::Input;
762    use std::fs;
763    use std::path::Path;
764    use tempfile::TempDir;
765
766    #[test]
767    fn history_jsonl_detection() {
768        assert!(is_history_jsonl(Path::new("/tmp/.vtcode/history/test.jsonl")));
769        assert!(!is_history_jsonl(Path::new("/tmp/.vtcode/history/test.txt")));
770        assert!(!is_history_jsonl(Path::new("/tmp/history/test.jsonl")));
771        assert!(!is_history_jsonl(Path::new("/tmp/.vtcode_evil/history/test.jsonl")));
772        assert!(!is_history_jsonl(Path::new("/tmp/.vtcode/history_evil/test.jsonl")));
773    }
774
775    #[test]
776    fn tool_output_spool_path_detection() {
777        assert!(is_tool_output_spool_path(Path::new(".vtcode/context/tool_outputs/run-123.txt")));
778        assert!(is_tool_output_spool_path(Path::new("/tmp/work/.vtcode/context/tool_outputs/run-123.txt")));
779        assert!(!is_tool_output_spool_path(Path::new(".vtcode/history/session.jsonl")));
780        assert!(!is_tool_output_spool_path(Path::new(".vtcode_evil/context/tool_outputs/run-123.txt")));
781        assert!(!is_tool_output_spool_path(Path::new(".vtcode/context/tool_outputs_evil/run-123.txt")));
782        assert!(!is_tool_output_spool_path(Path::new("/tmp/.vtcode/context/tool_outputs_malicious/run-123.txt")));
783    }
784
785    #[test]
786    fn pty_session_id_detection_from_tool_output_path() {
787        assert_eq!(
788            pty_session_id_from_tool_output_path(Path::new(".vtcode/context/tool_outputs/run-658ceef2.txt")),
789            Some("run-658ceef2".to_string())
790        );
791        assert_eq!(
792            pty_session_id_from_tool_output_path(Path::new(".vtcode/context/tool_outputs/command_session_123.txt")),
793            None
794        );
795    }
796
797    #[test]
798    fn build_read_handler_args_normalizes_zero_bounds() {
799        let canonical = Path::new("/tmp/example.txt");
800        let args = json!({
801            "path": "example.txt",
802            "offset": 0,
803            "limit": 0
804        });
805
806        let built = build_read_handler_args(&args, canonical);
807
808        assert_eq!(built["file_path"], json!("/tmp/example.txt"));
809        assert_eq!(built["offset"], json!(1));
810        assert!(built.get("limit").is_none());
811    }
812
813    #[test]
814    fn build_read_handler_args_avoids_duplicate_path_aliases() {
815        let canonical = Path::new("/tmp/example.txt");
816        let args = json!({
817            "path": "example.txt"
818        });
819
820        let built = build_read_handler_args(&args, canonical);
821        let parsed: ReadFileArgs = serde_json::from_value(built).unwrap();
822
823        assert_eq!(parsed.file_path, "/tmp/example.txt");
824        assert_eq!(parsed.offset, 1);
825    }
826
827    #[test]
828    fn build_read_handler_args_normalizes_indentation() {
829        let canonical = Path::new("/tmp/example.txt");
830
831        let args_true = json!({
832            "path": "example.txt",
833            "indentation": true
834        });
835        let built_true = build_read_handler_args(&args_true, canonical);
836        assert_eq!(built_true["mode"], json!("indentation"));
837        assert_eq!(built_true["indentation"], json!({}));
838
839        let args_false = json!({
840            "path": "example.txt",
841            "indentation": false,
842            "mode": "slice"
843        });
844        let built_false = build_read_handler_args(&args_false, canonical);
845        assert!(built_false.get("indentation").is_none());
846        assert_eq!(built_false["mode"], json!("slice"));
847
848        let args_false_indent_mode = json!({
849            "path": "example.txt",
850            "indentation": false,
851            "mode": "indentation"
852        });
853        let built_false_indent_mode = build_read_handler_args(&args_false_indent_mode, canonical);
854        assert!(built_false_indent_mode.get("indentation").is_none());
855        assert_eq!(built_false_indent_mode["mode"], json!("slice"));
856
857        let args_obj = json!({
858            "path": "example.txt",
859            "indentation": { "max_levels": 2 }
860        });
861        let built_obj = build_read_handler_args(&args_obj, canonical);
862        assert_eq!(built_obj["mode"], json!("indentation"));
863        assert_eq!(built_obj["indentation"]["max_levels"], json!(2));
864
865        let args_true_str = json!({
866            "path": "example.txt",
867            "indentation": "true"
868        });
869        let built_true_str = build_read_handler_args(&args_true_str, canonical);
870        assert_eq!(built_true_str["mode"], json!("indentation"));
871        assert_eq!(built_true_str["indentation"], json!({}));
872
873        let args_false_str = json!({
874            "path": "example.txt",
875            "indentation": "false"
876        });
877        let built_false_str = build_read_handler_args(&args_false_str, canonical);
878        assert!(built_false_str.get("indentation").is_none());
879    }
880
881    #[test]
882    fn build_read_handler_args_normalizes_mode() {
883        let canonical = Path::new("/tmp/example.txt");
884
885        let args_empty = json!({
886            "path": "example.txt",
887            "mode": ""
888        });
889        let built_empty = build_read_handler_args(&args_empty, canonical);
890        assert_eq!(built_empty["mode"], json!("slice"));
891
892        let args_whitespace = json!({
893            "path": "example.txt",
894            "mode": "   "
895        });
896        let built_whitespace = build_read_handler_args(&args_whitespace, canonical);
897        assert_eq!(built_whitespace["mode"], json!("slice"));
898
899        let args_unknown = json!({
900            "path": "example.txt",
901            "mode": "unknown"
902        });
903        let built_unknown = build_read_handler_args(&args_unknown, canonical);
904        assert_eq!(built_unknown["mode"], json!("slice"));
905
906        let args_indent = json!({
907            "path": "example.txt",
908            "mode": "Indentation"
909        });
910        let built_indent = build_read_handler_args(&args_indent, canonical);
911        assert_eq!(built_indent["mode"], json!("indentation"));
912    }
913
914    #[test]
915    fn build_read_handler_args_maps_start_end_to_offset_limit() {
916        let canonical = Path::new("/tmp/example.txt");
917
918        // start_line/end_line -> offset/limit
919        let args = json!({
920            "path": "example.txt",
921            "start_line": 550,
922            "end_line": 590
923        });
924        let built = build_read_handler_args(&args, canonical);
925        assert_eq!(built["offset"], json!(550));
926        assert_eq!(built["limit"], json!(41));
927
928        // start_line only -> offset set, limit unchanged
929        let args_start_only = json!({
930            "path": "example.txt",
931            "start_line": 100
932        });
933        let built_start_only = build_read_handler_args(&args_start_only, canonical);
934        assert_eq!(built_start_only["offset"], json!(100));
935        assert!(built_start_only.get("limit").is_none());
936
937        // explicit offset takes precedence over start_line
938        let args_explicit = json!({
939            "path": "example.txt",
940            "offset": 10,
941            "start_line": 550,
942            "end_line": 590
943        });
944        let built_explicit = build_read_handler_args(&args_explicit, canonical);
945        assert_eq!(built_explicit["offset"], json!(10));
946        assert_eq!(built_explicit["limit"], json!(41));
947    }
948
949    #[test]
950    fn history_cache_key_varies_with_offset() {
951        let temp_dir = TempDir::new().unwrap();
952        let history_dir = temp_dir.path().join(".vtcode/history");
953        fs::create_dir_all(&history_dir).unwrap();
954        let file_path = history_dir.join("session_0001.jsonl");
955        fs::write(&file_path, "line1\nline2\n").unwrap();
956
957        let metadata = fs::metadata(&file_path).unwrap();
958        let key_a =
959            build_history_cache_key(&file_path, &metadata, &json!({"offset_lines": 0, "page_size_lines": 1})).unwrap();
960        let key_b =
961            build_history_cache_key(&file_path, &metadata, &json!({"offset_lines": 1, "page_size_lines": 1})).unwrap();
962
963        assert_ne!(key_a, key_b);
964    }
965
966    #[tokio::test]
967    async fn test_read_file_paging_lines() {
968        let temp_dir = TempDir::new().unwrap();
969        let workspace_root = temp_dir.path().to_path_buf();
970        let test_file = workspace_root.join("test_file.txt");
971
972        // Create test content with 10 lines
973        let test_content = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
974        fs::write(&test_file, test_content).unwrap();
975
976        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
977        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
978
979        // Test basic paging functionality: offset_lines=2, page_size_lines=3
980        // Should return lines 3, 4, 5 (0-indexed: 2, 3, 4)
981        let args = json!({
982            "path": test_file.to_string_lossy().into_owned(),
983            "offset_lines": 2,
984            "page_size_lines": 3
985        });
986
987        let result = file_ops.read_file(args).await.unwrap();
988        assert!(result["success"].as_bool().unwrap());
989        assert_eq!(result["content"].as_str().unwrap(), "line3\nline4\nline5");
990    }
991
992    #[tokio::test]
993    async fn test_read_file_paging_lines_clamps_page_size_to_absolute_cap() {
994        let temp_dir = TempDir::new().unwrap();
995        let workspace_root = temp_dir.path().to_path_buf();
996        let test_file = workspace_root.join("over_cap.txt");
997        let absolute_cap = crate::tools::read_limits::absolute_line_cap();
998        let test_content = (1..=absolute_cap + 1)
999            .map(|line| format!("line{line}"))
1000            .collect::<Vec<_>>()
1001            .join("\n");
1002        fs::write(&test_file, test_content).unwrap();
1003
1004        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1005        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1006
1007        let result = file_ops
1008            .read_file(json!({
1009                "path": test_file.to_string_lossy().into_owned(),
1010                "offset_lines": 0,
1011                "page_size_lines": absolute_cap + 1
1012            }))
1013            .await
1014            .unwrap();
1015
1016        assert_eq!(result["content"].as_str().unwrap().lines().count(), absolute_cap);
1017        assert_eq!(result["truncated"], true);
1018    }
1019
1020    #[tokio::test]
1021    async fn test_read_file_paging_lines_preserves_lossy_utf8_behavior() {
1022        let temp_dir = TempDir::new().unwrap();
1023        let workspace_root = temp_dir.path().to_path_buf();
1024        let test_file = workspace_root.join("invalid_utf8.txt");
1025        fs::write(&test_file, b"before\n\xffafter\n").unwrap();
1026
1027        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1028        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1029
1030        let result = file_ops
1031            .read_file(json!({
1032                "path": test_file.to_string_lossy().into_owned(),
1033                "offset_lines": 1,
1034                "page_size_lines": 1
1035            }))
1036            .await
1037            .unwrap();
1038
1039        assert_eq!(result["content"].as_str().unwrap(), "\u{fffd}after");
1040    }
1041
1042    #[tokio::test]
1043    async fn test_read_file_paging_bytes() {
1044        let temp_dir = TempDir::new().unwrap();
1045        let workspace_root = temp_dir.path().to_path_buf();
1046        let test_file = workspace_root.join("test_file.txt");
1047
1048        let test_content = "line1\nline2\nline3\nline4\nline5\n";
1049        fs::write(&test_file, test_content).unwrap();
1050
1051        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1052        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1053
1054        // Test byte-based paging: skip first 6 bytes ("line1\n") and read next bytes
1055        // The new handler aligns to line boundaries and adds line numbers
1056        let args = json!({
1057            "path": test_file.to_string_lossy().into_owned(),
1058            "offset_bytes": 6,
1059            "page_size_bytes": 6
1060        });
1061
1062        let result = file_ops.read_file(args).await.unwrap();
1063        assert!(result["success"].as_bool().unwrap());
1064        // New handler returns line-numbered content aligned to line boundaries
1065        let content = result["content"].as_str().unwrap();
1066        assert!(content.contains("2: line2"));
1067    }
1068
1069    #[tokio::test]
1070    async fn test_read_file_offset_beyond_size() {
1071        let temp_dir = TempDir::new().unwrap();
1072        let workspace_root = temp_dir.path().to_path_buf();
1073        let test_file = workspace_root.join("test_file.txt");
1074
1075        let test_content = "line1\nline2\nline3\n";
1076        fs::write(&test_file, test_content).unwrap();
1077
1078        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1079        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1080
1081        // Test when offset is beyond file size
1082        let args = json!({
1083            "path": test_file.to_string_lossy().into_owned(),
1084            "offset_lines": 100,
1085            "page_size_lines": 10
1086        });
1087
1088        let result = file_ops.read_file(args).await.unwrap();
1089        assert!(result["success"].as_bool().unwrap());
1090        assert_eq!(result["content"].as_str().unwrap(), "");
1091    }
1092
1093    #[tokio::test]
1094    async fn test_read_file_empty_file() {
1095        let temp_dir = TempDir::new().unwrap();
1096        let workspace_root = temp_dir.path().to_path_buf();
1097        let test_file = workspace_root.join("empty_file.txt");
1098
1099        fs::write(&test_file, "").unwrap();
1100
1101        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1102        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1103
1104        // Test reading empty file with paging
1105        let args = json!({
1106            "path": test_file.to_string_lossy().into_owned(),
1107            "offset_lines": 0,
1108            "page_size_lines": 10
1109        });
1110
1111        let result = file_ops.read_file(args).await.unwrap();
1112        assert!(result["success"].as_bool().unwrap());
1113        assert_eq!(result["content"].as_str().unwrap(), "");
1114    }
1115
1116    #[tokio::test]
1117    async fn test_legacy_full_read_respects_absolute_cap() {
1118        let temp_dir = TempDir::new().unwrap();
1119        let workspace_root = temp_dir.path().to_path_buf();
1120        let test_file = workspace_root.join("big_legacy.txt");
1121        let content = (1..=1000).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1122        fs::write(&test_file, content).unwrap();
1123
1124        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1125        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1126
1127        // Bare legacy request (no max_* keys) would otherwise dump the whole file.
1128        let input: Input = serde_json::from_value(json!({ "path": test_file.to_string_lossy() })).unwrap();
1129        let (read_content, metadata, truncated) = file_ops.read_file_legacy(&test_file, &input).await.unwrap();
1130
1131        let cap = crate::tools::read_limits::read_limit_lines();
1132        assert_eq!(read_content.lines().count(), cap);
1133        assert!(truncated);
1134        assert_eq!(metadata["is_truncated"], true);
1135        assert_eq!(metadata["applied_max_lines"], cap);
1136    }
1137
1138    #[tokio::test]
1139    async fn test_legacy_full_read_bounds_oversized_lines() {
1140        let temp_dir = TempDir::new().unwrap();
1141        let workspace_root = temp_dir.path().to_path_buf();
1142        let test_file = workspace_root.join("giant_line.txt");
1143        let giant_line = "x".repeat(crate::tools::file_ops::MAX_LINE_READ_BYTES + 10_000);
1144        fs::write(&test_file, format!("{giant_line}\nlast\n")).unwrap();
1145
1146        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1147        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1148        let input: Input = serde_json::from_value(json!({ "path": test_file.to_string_lossy() })).unwrap();
1149
1150        let (content, metadata, truncated) = file_ops.read_file_legacy(&test_file, &input).await.unwrap();
1151
1152        assert!(truncated, "the bounded physical line makes the response incomplete");
1153        assert_eq!(metadata["size_lines"], 2);
1154        assert_eq!(metadata["line_truncated"], true);
1155        assert!(content.len() <= crate::tools::file_ops::MAX_LINE_READ_BYTES + "\nlast\n".len());
1156        assert!(content.ends_with("\nlast\n"));
1157
1158        let paged = file_ops
1159            .read_file(json!({
1160                "path": test_file.to_string_lossy(),
1161                "offset_lines": 0,
1162                "page_size_lines": 2
1163            }))
1164            .await
1165            .unwrap();
1166        assert_eq!(paged["truncation_reason"], "oversized_line");
1167    }
1168
1169    #[tokio::test]
1170    async fn test_legacy_full_read_preserves_trailing_newline() {
1171        let temp_dir = TempDir::new().unwrap();
1172        let workspace_root = temp_dir.path().to_path_buf();
1173        let test_file = workspace_root.join("legacy_newline.txt");
1174        fs::write(&test_file, "hello\n").unwrap();
1175
1176        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1177        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1178        let input: Input = serde_json::from_value(json!({ "path": test_file.to_string_lossy() })).unwrap();
1179
1180        let (content, _metadata, truncated) = file_ops.read_file_legacy(&test_file, &input).await.unwrap();
1181
1182        assert_eq!(content, "hello\n");
1183        assert!(!truncated);
1184    }
1185
1186    #[tokio::test]
1187    async fn test_read_file_legacy_functionality() {
1188        let temp_dir = TempDir::new().unwrap();
1189        let workspace_root = temp_dir.path().to_path_buf();
1190        let test_file = workspace_root.join("test_file.txt");
1191
1192        let test_content = "line1\nline2\nline3\nline4\nline5\n";
1193        fs::write(&test_file, test_content).unwrap();
1194
1195        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1196        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1197
1198        // Test legacy functionality with max_bytes
1199        let args = json!({
1200            "path": test_file.to_string_lossy().into_owned(),
1201            "max_bytes": 10
1202        });
1203
1204        let result = file_ops.read_file(args).await.unwrap();
1205        assert!(result["success"].as_bool().unwrap());
1206        let content = result["content"].as_str().unwrap();
1207        assert!(content.len() <= 10);
1208        assert!(content.starts_with("line1"));
1209    }
1210
1211    #[tokio::test]
1212    async fn test_read_file_full_text_preserves_trailing_newline() {
1213        let temp_dir = TempDir::new().unwrap();
1214        let workspace_root = temp_dir.path().to_path_buf();
1215        let test_file = workspace_root.join("note.txt");
1216        fs::write(&test_file, "hello\n").unwrap();
1217
1218        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1219        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1220
1221        let result = file_ops.read_file(json!({ "path": "note.txt" })).await.unwrap();
1222
1223        assert_eq!(result["content"].as_str(), Some("hello\n"));
1224    }
1225
1226    #[tokio::test]
1227    async fn test_read_file_legacy_token_chunking() {
1228        let temp_dir = TempDir::new().unwrap();
1229        let workspace_root = temp_dir.path().to_path_buf();
1230        let test_file = workspace_root.join("test_file.txt");
1231
1232        // Create test content with 50 lines
1233        let test_content = (1..=50).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n") + "\n";
1234        fs::write(&test_file, test_content).unwrap();
1235
1236        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1237        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1238
1239        // Token budget small enough to keep roughly first+last 5-10 lines
1240        let max_tokens = 15 * 12; // ~12 lines worth using TOKENS_PER_LINE
1241
1242        let args = json!({
1243            "path": test_file.to_string_lossy().into_owned(),
1244            "max_tokens": max_tokens
1245        });
1246
1247        let result = file_ops.read_file(args).await.unwrap();
1248        assert!(result["success"].as_bool().unwrap());
1249        let content = result["content"].as_str().unwrap();
1250        // Should contain first and last lines
1251        assert!(content.contains("line-1"));
1252        assert!(content.contains("line-50"));
1253        // Should indicate truncation
1254        assert!(result["is_truncated"].as_bool().unwrap());
1255        // Should report applied token budget
1256        #[allow(
1257            clippy::cast_sign_loss,
1258            reason = "Intentional compatibility, platform, or test-only suppression."
1259        )]
1260        let expected_max_tokens = max_tokens as u64; // safe: max_tokens is positive literal
1261        assert_eq!(result["metadata"]["data"]["applied_max_tokens"].as_u64().unwrap(), expected_max_tokens,);
1262    }
1263
1264    #[tokio::test]
1265    async fn test_read_file_legacy_token_limit_also_clamps_explicit_max_lines() {
1266        let temp_dir = TempDir::new().unwrap();
1267        let workspace_root = temp_dir.path().to_path_buf();
1268        let test_file = workspace_root.join("explicit_line_limit.txt");
1269        fs::write(&test_file, (1..=5).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n")).unwrap();
1270
1271        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1272        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1273
1274        let result = file_ops
1275            .read_file(json!({
1276                "path": test_file.to_string_lossy().into_owned(),
1277                "max_lines": 5,
1278                "max_tokens": 30
1279            }))
1280            .await
1281            .unwrap();
1282
1283        assert!(result["is_truncated"].as_bool().unwrap());
1284        assert!(result["content"].as_str().unwrap().contains("... 3 lines omitted ..."));
1285    }
1286
1287    #[tokio::test]
1288    async fn test_read_file_legacy_chunking_bounds_oversized_lines() {
1289        let temp_dir = TempDir::new().unwrap();
1290        let workspace_root = temp_dir.path().to_path_buf();
1291        let test_file = workspace_root.join("giant_chunked_line.txt");
1292        let giant_line = "x".repeat(crate::tools::file_ops::MAX_LINE_READ_BYTES + 10_000);
1293        fs::write(&test_file, format!("{giant_line}\nmiddle\nlast\n")).unwrap();
1294
1295        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1296        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1297
1298        let result = file_ops
1299            .read_file(json!({
1300                "path": test_file.to_string_lossy().into_owned(),
1301                "max_lines": 2
1302            }))
1303            .await
1304            .unwrap();
1305
1306        let content = result["content"].as_str().unwrap();
1307        assert!(result["is_truncated"].as_bool().unwrap());
1308        assert!(content.lines().next().unwrap().len() <= crate::tools::file_ops::MAX_LINE_READ_BYTES);
1309        assert!(content.contains("... 1 lines omitted ..."));
1310        assert!(content.ends_with("last"));
1311    }
1312
1313    #[tokio::test]
1314    async fn test_read_file_patch_payload_returns_actionable_error() {
1315        let temp_dir = TempDir::new().unwrap();
1316        let workspace_root = temp_dir.path().to_path_buf();
1317        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1318        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1319
1320        let args = json!({
1321            "input": "*** Begin Patch\n*** End Patch\n"
1322        });
1323        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1324
1325        assert!(err.contains("Patch content was sent to read_file"));
1326        assert!(err.contains("apply_patch"));
1327        assert!(err.contains("\"patch\":\"...\""));
1328    }
1329
1330    #[tokio::test]
1331    async fn test_missing_spool_file_returns_actionable_error() {
1332        let temp_dir = TempDir::new().unwrap();
1333        let workspace_root = temp_dir.path().to_path_buf();
1334        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1335        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1336
1337        let args = json!({
1338            "path": ".vtcode/context/tool_outputs/command_session_123.txt"
1339        });
1340        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1341
1342        assert!(err.contains("Spool file not found"));
1343        assert!(err.contains("Re-run the original tool command"));
1344    }
1345
1346    #[tokio::test]
1347    async fn test_missing_run_session_file_suggests_write_stdin() {
1348        let temp_dir = TempDir::new().unwrap();
1349        let workspace_root = temp_dir.path().to_path_buf();
1350        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1351        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1352
1353        let args = json!({
1354            "path": ".vtcode/context/tool_outputs/run-123abc.txt"
1355        });
1356        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1357
1358        assert!(err.contains("Session output file not found"));
1359        assert!(err.contains("write_stdin"));
1360        assert!(err.contains("run-123abc"));
1361    }
1362
1363    #[tokio::test]
1364    async fn test_missing_file_suggests_similar_workspace_file() {
1365        let temp_dir = TempDir::new().unwrap();
1366        let workspace_root = temp_dir.path().to_path_buf();
1367        fs::create_dir_all(workspace_root.join("src")).unwrap();
1368        fs::write(workspace_root.join("src/tool_exec.rs"), "fn main() {}\n").unwrap();
1369        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1370        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1371
1372        let args = json!({
1373            "path": "src/tool_exe.rs"
1374        });
1375        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1376
1377        assert!(err.contains("Did you mean"));
1378        assert!(err.contains("src/tool_exec.rs"));
1379    }
1380
1381    #[tokio::test]
1382    async fn test_directory_path_returns_actionable_list_guidance() {
1383        let temp_dir = TempDir::new().unwrap();
1384        let workspace_root = temp_dir.path().to_path_buf();
1385        fs::create_dir_all(workspace_root.join("src/agent")).unwrap();
1386        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1387        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1388
1389        let args = json!({
1390            "path": "src"
1391        });
1392        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1393
1394        assert!(err.contains("is a directory, not a file"));
1395        assert!(err.contains("exec_command.cmd"));
1396        assert!(err.contains("find"));
1397        assert!(err.contains("ls"));
1398    }
1399
1400    #[tokio::test]
1401    async fn test_spool_file_reads_are_chunked_with_next_offset() {
1402        let temp_dir = TempDir::new().unwrap();
1403        let workspace_root = temp_dir.path().to_path_buf();
1404        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1405        fs::create_dir_all(&spool_dir).unwrap();
1406        let spool_file = spool_dir.join("command_session_123.txt");
1407        let spool_content = (1..=120).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1408        fs::write(&spool_file, spool_content).unwrap();
1409
1410        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1411        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1412
1413        let args = json!({
1414            "path": ".vtcode/context/tool_outputs/command_session_123.txt"
1415        });
1416
1417        let result = file_ops.read_file(args).await.unwrap();
1418        assert_eq!(result["success"], true);
1419        assert_eq!(result["spool_chunked"], true);
1420        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
1421        assert_eq!(result["has_more"], true);
1422        assert_eq!(
1423            result["next_read_args"],
1424            json!({
1425                "path": ".vtcode/context/tool_outputs/command_session_123.txt",
1426                "offset": SPOOL_CHUNK_DEFAULT_LIMIT_LINES + 1,
1427                "limit": SPOOL_CHUNK_DEFAULT_LIMIT_LINES
1428            })
1429        );
1430        assert!(result.get("chunk_limit").is_none());
1431        assert!(result.get("next_offset").is_none());
1432        assert!(result.get("preferred_next_action").is_none());
1433        assert!(result.get("follow_up_prompt").is_none());
1434    }
1435
1436    #[tokio::test]
1437    async fn test_spool_file_last_chunk_omits_continuation_fields() {
1438        let temp_dir = TempDir::new().unwrap();
1439        let workspace_root = temp_dir.path().to_path_buf();
1440        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1441        fs::create_dir_all(&spool_dir).unwrap();
1442        let spool_file = spool_dir.join("command_session_999.txt");
1443        let spool_content = (1..=5).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1444        fs::write(&spool_file, spool_content).unwrap();
1445
1446        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1447        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1448
1449        let args = json!({
1450            "path": ".vtcode/context/tool_outputs/command_session_999.txt"
1451        });
1452
1453        let result = file_ops.read_file(args).await.unwrap();
1454        assert_eq!(result["success"], true);
1455        assert_eq!(result["spool_chunked"], true);
1456        assert_eq!(result["lines_returned"], 5);
1457        assert!(result.get("has_more").is_none());
1458        assert!(result.get("next_read_args").is_none());
1459        assert!(result.get("follow_up_prompt").is_none());
1460        assert!(result.get("chunk_limit").is_none());
1461        assert!(result.get("next_offset").is_none());
1462        assert!(result.get("preferred_next_action").is_none());
1463    }
1464
1465    #[tokio::test]
1466    async fn test_spool_file_exact_limit_at_eof_omits_continuation_fields() {
1467        let temp_dir = TempDir::new().unwrap();
1468        let workspace_root = temp_dir.path().to_path_buf();
1469        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1470        fs::create_dir_all(&spool_dir).unwrap();
1471        let spool_file = spool_dir.join("command_session_exact.txt");
1472        let spool_content = (1..=SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
1473            .map(|i| format!("line{i}"))
1474            .collect::<Vec<_>>()
1475            .join("\n");
1476        fs::write(&spool_file, spool_content).unwrap();
1477
1478        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1479        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1480
1481        let result = file_ops
1482            .read_file(json!({
1483                "path": ".vtcode/context/tool_outputs/command_session_exact.txt"
1484            }))
1485            .await
1486            .unwrap();
1487
1488        assert_eq!(result["success"], true);
1489        assert_eq!(result["spool_chunked"], true);
1490        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
1491        assert!(result.get("has_more").is_none());
1492        assert!(result.get("next_read_args").is_none());
1493    }
1494
1495    #[tokio::test]
1496    async fn test_read_file_absolute_cap_adds_continuation() {
1497        let temp_dir = TempDir::new().unwrap();
1498        let workspace_root = temp_dir.path().to_path_buf();
1499        let test_file = workspace_root.join("big.txt");
1500        let content = (1..=1000).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1501        fs::write(&test_file, content).unwrap();
1502
1503        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1504        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1505
1506        // Request far more than the cap without pagination.
1507        let result = file_ops
1508            .read_file(json!({
1509                "path": "big.txt",
1510                "limit": 2000
1511            }))
1512            .await
1513            .unwrap();
1514
1515        assert_eq!(result["success"], true);
1516        assert_eq!(result["capped_by_limit"], true);
1517        assert_eq!(result["has_more"], true);
1518        assert!(result["next_read_args"].is_object());
1519        assert_eq!(result["next_read_args"]["path"], "big.txt");
1520        // offset 1 + applied cap (default 400) lines read.
1521        assert_eq!(result["next_read_args"]["offset"], 401);
1522        assert_eq!(result["next_read_args"]["limit"], crate::tools::read_limits::read_limit_lines());
1523    }
1524
1525    #[tokio::test]
1526    async fn path_only_read_uses_configured_cap_for_continuation() {
1527        let original_config = file_read_cache_config();
1528        let mut configured = original_config.clone();
1529        configured.max_read_lines = 800;
1530        crate::tools::cache::configure_file_cache(&configured);
1531
1532        let temp_dir = TempDir::new().unwrap();
1533        let workspace_root = temp_dir.path().to_path_buf();
1534        let test_file = workspace_root.join("configured-cap.txt");
1535        let content = (1..=1000).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1536        fs::write(&test_file, content).unwrap();
1537
1538        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1539        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1540        let result = file_ops.read_file(json!({ "path": "configured-cap.txt" })).await;
1541
1542        crate::tools::cache::configure_file_cache(&original_config);
1543        let result = result.unwrap();
1544
1545        assert_eq!(result["has_more"], true);
1546        assert_eq!(result["next_read_args"]["offset"], 801);
1547        assert_eq!(result["next_read_args"]["limit"], 800);
1548    }
1549
1550    #[tokio::test]
1551    async fn test_read_file_cap_continuation_chains_across_pages() {
1552        // Regresses the bug where the continuation chain died after the first
1553        // clamped page: follow-up pages pass `applied_limit` back as `limit`,
1554        // so `capped_by_limit` is false and no `next_read_args` was emitted.
1555        let temp_dir = TempDir::new().unwrap();
1556        let workspace_root = temp_dir.path().to_path_buf();
1557        let test_file = workspace_root.join("big.txt");
1558        let content = (1..=1000).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1559        fs::write(&test_file, content).unwrap();
1560
1561        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1562        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1563
1564        // Page 1: clamps the oversized request to the cap.
1565        let page1 = file_ops.read_file(json!({ "path": "big.txt", "limit": 2000 })).await.unwrap();
1566        assert_eq!(page1["capped_by_limit"], true);
1567        assert_eq!(page1["has_more"], true);
1568        assert_eq!(page1["next_read_args"]["offset"], 401);
1569        let cap = crate::tools::read_limits::read_limit_lines();
1570
1571        // Page 2: follows the continuation. `capped_by_limit` is now false, but
1572        // the chain must still surface `next_read_args` because the read hit
1573        // the ceiling again.
1574        let page2 = file_ops
1575            .read_file(json!({
1576                "path": "big.txt",
1577                "offset": page1["next_read_args"]["offset"],
1578                "limit": page1["next_read_args"]["limit"],
1579            }))
1580            .await
1581            .unwrap();
1582        assert_eq!(page2["capped_by_limit"], false);
1583        assert_eq!(page2["has_more"], true);
1584        assert!(page2["next_read_args"].is_object());
1585        assert_eq!(page2["next_read_args"]["offset"], 401 + cap);
1586        assert_eq!(page2["next_read_args"]["limit"], cap);
1587
1588        // Page 3: final partial chunk, no more continuation.
1589        let page3 = file_ops
1590            .read_file(json!({
1591                "path": "big.txt",
1592                "offset": page2["next_read_args"]["offset"],
1593                "limit": page2["next_read_args"]["limit"],
1594            }))
1595            .await
1596            .unwrap();
1597        assert!(page3.get("has_more").is_none());
1598        assert!(page3.get("next_read_args").is_none());
1599    }
1600
1601    #[tokio::test]
1602    async fn test_read_file_accepts_compact_spool_continuation_args() {
1603        let temp_dir = TempDir::new().unwrap();
1604        let workspace_root = temp_dir.path().to_path_buf();
1605        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1606        fs::create_dir_all(&spool_dir).unwrap();
1607        let spool_file = spool_dir.join("command_session_456.txt");
1608        let spool_content = (1..=120).map(|i| format!("line{i}")).collect::<Vec<_>>().join("\n");
1609        fs::write(&spool_file, spool_content).unwrap();
1610
1611        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1612        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1613
1614        let result = file_ops
1615            .read_file(json!({
1616                "p": ".vtcode/context/tool_outputs/command_session_456.txt",
1617                "o": 81,
1618                "l": 40
1619            }))
1620            .await
1621            .unwrap();
1622
1623        assert_eq!(result["success"], true);
1624        assert_eq!(result["path"], ".vtcode/context/tool_outputs/command_session_456.txt");
1625        assert_eq!(result["spool_chunked"], true);
1626        assert_eq!(result["lines_returned"], 40);
1627        assert_eq!(result["content"].as_str().unwrap().lines().next(), Some("line81"));
1628        assert!(result.get("has_more").is_none());
1629        assert!(result.get("next_read_args").is_none());
1630    }
1631
1632    #[tokio::test]
1633    async fn bounded_line_read_truncates_oversized_lines_and_stays_aligned() {
1634        use tokio::io::BufReader;
1635
1636        // A physical line longer than MAX_LINE_READ_BYTES must be truncated for
1637        // display while the reader stays aligned at the next newline.
1638        let long_line = "x".repeat(crate::tools::file_ops::MAX_LINE_READ_BYTES + 10_000);
1639        let body = format!("{long_line}\nsecond\nthird\n");
1640        let cursor = std::io::Cursor::new(body.into_bytes());
1641        let mut reader = BufReader::new(cursor);
1642        let mut buffer = Vec::new();
1643
1644        let first = crate::tools::file_ops::read_bounded_line(&mut reader, &mut buffer)
1645            .await
1646            .unwrap()
1647            .expect("first line present");
1648        assert!(first, "oversized line must report truncation");
1649        assert!(buffer.len() <= crate::tools::file_ops::MAX_LINE_READ_BYTES);
1650
1651        let second = crate::tools::file_ops::read_bounded_line(&mut reader, &mut buffer)
1652            .await
1653            .unwrap()
1654            .expect("second line present");
1655        assert!(!second, "normal line must not report truncation");
1656        assert_eq!(buffer, b"second\n");
1657
1658        let third = crate::tools::file_ops::read_bounded_line(&mut reader, &mut buffer)
1659            .await
1660            .unwrap()
1661            .expect("third line present");
1662        assert!(!third);
1663        assert_eq!(buffer, b"third\n");
1664
1665        assert!(
1666            crate::tools::file_ops::read_bounded_line(&mut reader, &mut buffer)
1667                .await
1668                .unwrap()
1669                .is_none(),
1670            "stream must be exhausted after the third line"
1671        );
1672    }
1673}