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