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_json::{Value, json};
17use std::path::Path;
18use std::time::UNIX_EPOCH;
19
20const SPOOL_CHUNK_DEFAULT_LIMIT_LINES: usize = DEFAULT_NEXT_READ_LIMIT;
21const SPOOL_CHUNK_MAX_LIMIT_LINES: usize = 50;
22const SPOOL_CHUNK_SENTINEL_MAX_TOKENS: usize = 4096;
23
24#[derive(Clone, Copy, Debug)]
25struct SpoolChunkPlan {
26    offset: usize,
27    limit: usize,
28}
29
30fn is_legacy_read_request(args: &Value, is_image: bool) -> bool {
31    if is_image {
32        return true;
33    }
34
35    if let Some(obj) = args.as_object() {
36        obj.keys().any(|key| {
37            matches!(
38                key.as_str(),
39                "offset_bytes"
40                    | "page_size_bytes"
41                    | "offset_lines"
42                    | "page_size_lines"
43                    | "max_bytes"
44                    | "max_lines"
45                    | "chunk_lines"
46                    | "encoding"
47                    | "max_tokens"
48            )
49        })
50    } else {
51        false
52    }
53}
54
55fn is_new_read_request(args: &Value) -> bool {
56    let new_keys = ["mode", "indentation", "offset", "limit", "o", "l"];
57    new_keys.iter().any(|key| args.get(*key).is_some())
58}
59
60fn looks_like_patch_payload(args: &Value) -> bool {
61    fn looks_like_patch_text(text: &str) -> bool {
62        let trimmed = text.trim_start();
63        trimmed.starts_with("*** Begin Patch")
64            || trimmed.starts_with("*** Update File:")
65            || trimmed.starts_with("*** Add File:")
66            || trimmed.starts_with("*** Delete File:")
67    }
68
69    args.get("patch")
70        .and_then(Value::as_str)
71        .is_some_and(looks_like_patch_text)
72        || args
73            .get("input")
74            .and_then(Value::as_str)
75            .is_some_and(looks_like_patch_text)
76        || args.as_str().is_some_and(looks_like_patch_text)
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(
86            "file_path".to_string(),
87            json!(canonical_path.to_string_lossy()),
88        );
89
90        if let Some(mode_value) = obj.get("mode").cloned() {
91            let normalized_mode = match mode_value {
92                Value::Null => None,
93                Value::String(raw) => {
94                    let trimmed = raw.trim();
95                    if trimmed.eq_ignore_ascii_case("indentation") {
96                        Some("indentation".to_string())
97                    } else {
98                        Some("slice".to_string())
99                    }
100                }
101                _ => Some("slice".to_string()),
102            };
103
104            if let Some(mode) = normalized_mode {
105                obj.insert("mode".to_string(), json!(mode));
106            } else {
107                obj.remove("mode");
108            }
109        }
110
111        if let Some(indentation_value) = obj.get("indentation").cloned() {
112            match indentation_value {
113                Value::Bool(true) => {
114                    obj.insert("mode".to_string(), json!("indentation"));
115                    obj.insert("indentation".to_string(), json!({}));
116                }
117                Value::Bool(false) | Value::Null => {
118                    obj.remove("indentation");
119                    if obj
120                        .get("mode")
121                        .and_then(Value::as_str)
122                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
123                    {
124                        obj.insert("mode".to_string(), json!("slice"));
125                    }
126                }
127                Value::String(text) if text.eq_ignore_ascii_case("true") => {
128                    obj.insert("mode".to_string(), json!("indentation"));
129                    obj.insert("indentation".to_string(), json!({}));
130                }
131                Value::String(text) if text.eq_ignore_ascii_case("false") => {
132                    obj.remove("indentation");
133                    if obj
134                        .get("mode")
135                        .and_then(Value::as_str)
136                        .is_some_and(|mode| mode.eq_ignore_ascii_case("indentation"))
137                    {
138                        obj.insert("mode".to_string(), json!("slice"));
139                    }
140                }
141                Value::Object(_) => {
142                    obj.insert("mode".to_string(), json!("indentation"));
143                }
144                _ => {}
145            }
146        }
147
148        if let Some(src) = obj.get("offset_lines").or_else(|| obj.get("o")).cloned() {
149            obj.entry("offset".to_string()).or_insert(src);
150        }
151        if let Some(src) = obj.get("page_size_lines").or_else(|| obj.get("l")).cloned() {
152            obj.entry("limit".to_string()).or_insert(src);
153        }
154
155        // Map start_line/end_line to offset/limit when offset is not already set.
156        if let Some(start) = obj.get("start_line").and_then(parse_usize_value) {
157            obj.entry("offset".to_string()).or_insert(json!(start));
158            if let Some(end) = obj.get("end_line").and_then(parse_usize_value)
159                && end >= start
160                && !obj.contains_key("limit")
161            {
162                obj.insert("limit".to_string(), json!(end - start + 1));
163            }
164        }
165
166        if obj.get("offset").and_then(parse_usize_value) == Some(0) {
167            obj.insert("offset".to_string(), json!(1));
168        }
169        if obj.get("limit").and_then(parse_usize_value) == Some(0) {
170            obj.remove("limit");
171        }
172    }
173
174    handler_args_json
175}
176
177fn parse_usize_value(value: &Value) -> Option<usize> {
178    value
179        .as_u64()
180        .and_then(|n| usize::try_from(n).ok())
181        .or_else(|| value.as_str().and_then(|s| s.parse::<usize>().ok()))
182}
183
184fn has_explicit_limit(args: &Value) -> bool {
185    [
186        "limit",
187        "l",
188        "page_size_lines",
189        "max_lines",
190        "chunk_lines",
191        "end_line",
192    ]
193    .iter()
194    .any(|key| args.get(*key).is_some())
195}
196
197fn has_explicit_offset(args: &Value) -> bool {
198    ["offset", "o", "offset_lines", "offset_bytes", "start_line"]
199        .iter()
200        .any(|key| args.get(*key).is_some())
201}
202
203fn is_full_text_read(args: &Value, is_spool_output: bool) -> bool {
204    !is_spool_output && !has_explicit_offset(args) && !has_explicit_limit(args)
205}
206
207use super::restore_exact_text_content;
208
209fn response_size_bytes(response: &Value) -> Option<u64> {
210    response
211        .get("metadata")
212        .and_then(|metadata| metadata.get("data"))
213        .and_then(|data| data.get("size_bytes"))
214        .and_then(Value::as_u64)
215}
216
217fn apply_spool_chunk_defaults(handler_args_json: &mut Value, raw_args: &Value) -> SpoolChunkPlan {
218    let mut offset = 1usize;
219    let mut limit = SPOOL_CHUNK_DEFAULT_LIMIT_LINES;
220
221    if let Some(obj) = handler_args_json.as_object_mut() {
222        offset = obj
223            .get("offset")
224            .and_then(parse_usize_value)
225            .unwrap_or(1)
226            .max(1);
227
228        let requested_limit = if has_explicit_limit(raw_args) {
229            raw_args
230                .get("limit")
231                .or_else(|| raw_args.get("l"))
232                .or_else(|| raw_args.get("page_size_lines"))
233                .or_else(|| raw_args.get("max_lines"))
234                .or_else(|| raw_args.get("chunk_lines"))
235                .and_then(parse_usize_value)
236                .unwrap_or(SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
237        } else {
238            SPOOL_CHUNK_DEFAULT_LIMIT_LINES
239        };
240
241        limit = requested_limit.clamp(1, SPOOL_CHUNK_MAX_LIMIT_LINES);
242
243        obj.insert("offset".to_string(), json!(offset));
244        obj.insert("limit".to_string(), json!(limit));
245        // Preserve narrow chunking behavior by bypassing MIN_BATCH_LIMIT expansion
246        // in ReadFileHandler::handle (which only applies when max_tokens is absent).
247        obj.entry("max_tokens".to_string())
248            .or_insert_with(|| json!(SPOOL_CHUNK_SENTINEL_MAX_TOKENS));
249    }
250
251    SpoolChunkPlan { offset, limit }
252}
253
254fn is_history_jsonl(path: &Path) -> bool {
255    // Fast path: avoid component iteration if extension doesn't match
256    if !path.to_string_lossy().ends_with(".jsonl") {
257        return false;
258    }
259
260    let s = path.to_string_lossy();
261    s.contains(".vtcode") && s.contains("/history/")
262}
263
264fn is_tool_output_spool_path(path: &Path) -> bool {
265    let s = path.to_string_lossy();
266    s.contains(".vtcode") && s.contains("/context/tool_outputs/")
267}
268
269fn pty_session_id_from_tool_output_path(path: &Path) -> Option<String> {
270    let file_name = path.file_name()?.to_str()?;
271    let session_id = file_name.strip_suffix(".txt")?;
272    if session_id.starts_with("run-")
273        && session_id.len() > "run-".len()
274        && session_id
275            .chars()
276            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
277    {
278        Some(session_id.to_string())
279    } else {
280        None
281    }
282}
283
284fn build_history_cache_key(
285    path: &Path,
286    metadata: &std::fs::Metadata,
287    args: &Value,
288) -> Option<String> {
289    let modified = metadata.modified().ok()?;
290    let mtime = modified.duration_since(UNIX_EPOCH).ok()?.as_millis();
291    let size = metadata.len();
292
293    let mode = args.get("mode").and_then(Value::as_str).unwrap_or("legacy");
294    let indentation = args
295        .get("indentation")
296        .and_then(Value::as_str)
297        .unwrap_or("");
298    let offset = args
299        .get("offset")
300        .or_else(|| args.get("o"))
301        .or_else(|| args.get("offset_lines"))
302        .or_else(|| args.get("offset_bytes"))
303        .and_then(Value::as_u64)
304        .unwrap_or(0);
305    let limit = args
306        .get("limit")
307        .or_else(|| args.get("l"))
308        .or_else(|| args.get("page_size_lines"))
309        .or_else(|| args.get("page_size_bytes"))
310        .and_then(Value::as_u64)
311        .unwrap_or(0);
312    let max_bytes = args.get("max_bytes").and_then(Value::as_u64).unwrap_or(0);
313    let max_tokens = args.get("max_tokens").and_then(Value::as_u64).unwrap_or(0);
314    let encoding = args.get("encoding").and_then(Value::as_str).unwrap_or("");
315
316    Some(format!(
317        "read_file:history:{}:{}:{}:mode={mode}|indent={indentation}|offset={offset}|limit={limit}|max_bytes={max_bytes}|max_tokens={max_tokens}|encoding={encoding}",
318        path.display(),
319        size,
320        mtime
321    ))
322}
323
324impl FileOpsTool {
325    async fn track_read_snapshot(&self, path: &Path) {
326        if let Err(err) = self.edited_file_monitor.track_read(path).await {
327            tracing::warn!(
328                path = %path.display(),
329                error = %err,
330                "Failed to record edited-file read snapshot"
331            );
332        }
333    }
334
335    fn track_read_text_snapshot(&self, path: &Path, content: &str) {
336        if let Err(err) = self.edited_file_monitor.record_read_text(path, content) {
337            tracing::warn!(
338                path = %path.display(),
339                error = %err,
340                "Failed to record edited-file read snapshot"
341            );
342        }
343    }
344
345    async fn track_exact_text_snapshot(&self, path: &Path, content: &str, size_bytes: u64) {
346        if let Some(exact_content) = restore_exact_text_content(content, size_bytes) {
347            self.track_read_text_snapshot(path, &exact_content);
348        } else {
349            self.track_read_snapshot(path).await;
350        }
351    }
352
353    async fn track_cached_read_snapshot(&self, path: &Path, args: &Value, response: &Value) {
354        if is_full_text_read(args, false)
355            && let Some(content) = response.get("content").and_then(Value::as_str)
356            && let Some(size_bytes) = response_size_bytes(response)
357        {
358            self.track_exact_text_snapshot(path, content, size_bytes)
359                .await;
360            return;
361        }
362
363        self.track_read_snapshot(path).await;
364    }
365
366    pub async fn read_file(&self, args: Value) -> Result<Value> {
367        let mut perf = PerfSpan::new("vtcode.perf.read_file_ms");
368
369        let path_args: PathArgs = serde_json::from_value(args.clone()).map_err(|_| {
370            if looks_like_patch_payload(&args) {
371                return anyhow!(
372                    "Error: Patch content was sent to read_file.\n\
373                    Use the patch path instead: unified_file with {{\"action\":\"patch\",\"patch\":\"...\"}} \
374                    (or {{\"action\":\"patch\",\"input\":\"...\"}}).\n\
375                    read_file requires a path parameter."
376                );
377            }
378            let received = serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string());
379            anyhow!(
380                "Error: Invalid 'read_file' arguments. Missing required path parameter.\n\
381                Received: {}\n\
382                Expected: {{\"path\": \"file/path\"}} or {{\"file_path\": \"file/path\"}}\n\
383                Accepted path parameters: path, file_path, filepath, target_path, file, p\n\
384                Optional params: offset_lines, limit, max_bytes, max_tokens",
385                received
386            )
387        })?;
388
389        let path_str = &path_args.path;
390
391        // Try to resolve the file path
392        let potential_paths = self.resolve_file_path(path_str)?;
393        let missing_spool_candidate = potential_paths
394            .iter()
395            .find(|candidate| is_tool_output_spool_path(candidate.as_path()))
396            .cloned();
397        let mut directory_candidate = None;
398
399        for candidate_path in &potential_paths {
400            if !tokio::fs::try_exists(candidate_path).await? {
401                continue;
402            }
403
404            let canonical = self
405                .normalize_and_validate_candidate(candidate_path, path_str)
406                .await?;
407
408            if self.should_exclude(&canonical).await {
409                continue;
410            }
411
412            let metadata = tokio::fs::metadata(&canonical).await?;
413            if !metadata.is_file() {
414                if metadata.is_dir() && directory_candidate.is_none() {
415                    directory_candidate = Some(canonical);
416                }
417                continue;
418            }
419
420            let size_bytes = metadata.len();
421            let history_jsonl = is_history_jsonl(&canonical);
422            perf.tag(
423                "path_class",
424                if history_jsonl {
425                    "history_jsonl"
426                } else {
427                    "other"
428                },
429            );
430            let is_image = is_image_path(&canonical);
431            let is_spool_output = is_tool_output_spool_path(&canonical);
432            let is_legacy_request = is_legacy_read_request(&args, is_image);
433            let is_new_request = is_new_read_request(&args);
434
435            let cache_config = file_read_cache_config();
436            let cache_key = if cache_config.enabled
437                && history_jsonl
438                && size_bytes >= cache_config.min_size_bytes as u64
439                && size_bytes <= cache_config.max_size_bytes as u64
440            {
441                build_history_cache_key(&canonical, &metadata, &args)
442            } else {
443                None
444            };
445
446            if let Some(key) = cache_key.as_ref()
447                && let Some(cached) = FILE_CACHE.get_file(key).await
448            {
449                perf.tag("cache", "hit");
450                self.track_cached_read_snapshot(&canonical, &args, &cached)
451                    .await;
452                return Ok(cached);
453            }
454            perf.tag("cache", if cache_key.is_some() { "miss" } else { "skip" });
455
456            if !is_image && (is_new_request || !is_legacy_request) {
457                let mut handler_args_json = build_read_handler_args(&args, &canonical);
458                let spool_plan = if is_spool_output {
459                    Some(apply_spool_chunk_defaults(&mut handler_args_json, &args))
460                } else {
461                    None
462                };
463                match serde_json::from_value::<ReadFileArgs>(handler_args_json) {
464                    Ok(read_args) => {
465                        let requested_path = self.workspace_relative_display(&canonical);
466                        let handler = ReadFileHandler;
467                        let ReadFileOutcome {
468                            content,
469                            lines_read: lines_returned,
470                            has_more,
471                        } = handler.handle_detailed(read_args).await?;
472                        let full_text_read = is_full_text_read(&args, is_spool_output);
473                        let response_content = if full_text_read {
474                            restore_exact_text_content(&content, size_bytes)
475                                .unwrap_or_else(|| content.clone())
476                        } else {
477                            content.clone()
478                        };
479
480                        let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
481                            .success()
482                            .message(format!("Successfully read file {}", requested_path))
483                            .content(&response_content)
484                            .field("path", json!(requested_path.clone()))
485                            .field("no_spool", json!(true))
486                            .field("content_kind", json!("text"))
487                            .field("encoding", json!("utf8"))
488                            .data("size_bytes", json!(size_bytes))
489                            .data("content_kind", json!("text"))
490                            .data("encoding", json!("utf8"));
491
492                        if let Some(plan) = spool_plan {
493                            let next_offset = plan.offset.saturating_add(lines_returned);
494
495                            builder = builder
496                                .field("spool_chunked", json!(true))
497                                .field("lines_returned", json!(lines_returned));
498                            if has_more {
499                                builder = builder.field("has_more", json!(true));
500                                builder = builder.field(
501                                    "next_read_args",
502                                    ReadChunkContinuationArgs::new(
503                                        requested_path.clone(),
504                                        next_offset,
505                                        plan.limit,
506                                    )
507                                    .to_value(),
508                                );
509                            }
510                        }
511
512                        let response = builder.build_json();
513
514                        if full_text_read {
515                            self.track_exact_text_snapshot(
516                                &canonical,
517                                &response_content,
518                                size_bytes,
519                            )
520                            .await;
521                        } else {
522                            self.track_read_snapshot(&canonical).await;
523                        }
524                        if let Some(key) = cache_key.as_ref() {
525                            FILE_CACHE.put_file(key.clone(), response.clone()).await;
526                        }
527
528                        return Ok(response);
529                    }
530                    Err(e) => {
531                        if is_new_request {
532                            return Err(anyhow!(
533                                "Failed to parse arguments for read_file handler: {}. Args: {:?}",
534                                e,
535                                args
536                            ));
537                        }
538                    }
539                }
540            }
541
542            // Legacy path
543            let input: Input = serde_json::from_value(args.clone())
544                .context("Error: Invalid 'read_file' arguments for legacy handler.")?;
545
546            // Check if paging/offset is requested
547            let use_paging = input.offset_bytes.is_some()
548                || input.page_size_bytes.is_some()
549                || input.offset_lines.is_some()
550                || input.page_size_lines.is_some();
551
552            let (content, metadata, truncated) = if use_paging {
553                self.read_file_paged(&canonical, &input).await?
554            } else {
555                self.read_file_legacy(&canonical, &input).await?
556            };
557
558            let mut builder = ToolResponseBuilder::new(tools::READ_FILE)
559                .success()
560                .message(format!(
561                    "Successfully read {} bytes from {}",
562                    size_bytes,
563                    self.workspace_relative_display(&canonical)
564                ))
565                .content(&content)
566                .field("path", json!(self.workspace_relative_display(&canonical)))
567                .field("no_spool", json!(true));
568
569            // Merge legacy metadata - extract actual data fields, not wrapper structure
570            if let Some(obj) = metadata.as_object() {
571                for (k, v) in obj {
572                    builder = builder.data(k.clone(), v.clone());
573                }
574            }
575
576            // Special legacy fields at top level
577            if let Some(is_truncated) = metadata.get("is_truncated").and_then(Value::as_bool) {
578                builder = builder.field("is_truncated", json!(is_truncated));
579            }
580            if let Some(encoding) = metadata.get("encoding").and_then(Value::as_str) {
581                builder = builder.field("encoding", json!(encoding));
582            }
583            if let Some(content_kind) = metadata.get("content_kind").and_then(Value::as_str) {
584                builder = builder.field("content_kind", json!(content_kind));
585                if matches!(content_kind, "binary" | "image") {
586                    builder = builder.field("binary", json!(true));
587                }
588            }
589            if let Some(mime_type) = metadata.get("mime_type").and_then(Value::as_str) {
590                builder = builder.field("mime_type", json!(mime_type));
591            }
592
593            // Add paging information
594            if use_paging {
595                if let Some(offset_bytes) = input.offset_bytes {
596                    builder = builder.field("offset_bytes", json!(offset_bytes));
597                }
598                if let Some(page_size_bytes) = input.page_size_bytes {
599                    builder = builder.field("page_size_bytes", json!(page_size_bytes));
600                }
601                if let Some(offset_lines) = input.offset_lines {
602                    builder = builder.field("offset_lines", json!(offset_lines));
603                }
604                if let Some(page_size_lines) = input.page_size_lines {
605                    builder = builder.field("page_size_lines", json!(page_size_lines));
606                }
607                if truncated {
608                    builder = builder.field("truncated", json!(true));
609                    builder = builder.field("truncation_reason", json!("reached_end_of_file"));
610                }
611            }
612
613            let content_kind = metadata
614                .get("content_kind")
615                .and_then(Value::as_str)
616                .unwrap_or("text");
617            let full_legacy_text_read = !use_paging && !truncated && content_kind == "text";
618            let response = builder.build_json();
619            if full_legacy_text_read {
620                self.track_exact_text_snapshot(&canonical, &content, size_bytes)
621                    .await;
622            } else {
623                self.track_read_snapshot(&canonical).await;
624            }
625            if let Some(key) = cache_key.as_ref() {
626                FILE_CACHE.put_file(key.clone(), response.clone()).await;
627            }
628
629            return Ok(response);
630        }
631
632        if let Some(spool_path) = missing_spool_candidate {
633            if let Some(session_id) = pty_session_id_from_tool_output_path(&spool_path) {
634                return Err(anyhow!(
635                    "Error: Session output file not found: {}. This looks like a command session id. Use unified_exec with session_id=\"{}\" instead of read_file.",
636                    self.workspace_relative_display(&spool_path),
637                    session_id,
638                ));
639            }
640            return Err(anyhow!(
641                "Error: Spool file not found (possibly expired): {}. Re-run the original tool command to regenerate this output.",
642                self.workspace_relative_display(&spool_path),
643            ));
644        }
645
646        if let Some(directory_path) = directory_candidate {
647            let display_path = self.workspace_relative_display(&directory_path);
648            return Err(anyhow!(
649                "Error: Path '{}' is a directory, not a file. Use unified_search with action=\"list\" and path=\"{}\" to inspect it, or set mode=\"recursive\" for nested discovery.",
650                display_path,
651                display_path,
652            ));
653        }
654
655        Err(anyhow!(
656            "Error: File not found: {}. Tried paths: {}.{}",
657            path_str,
658            potential_paths
659                .iter()
660                .map(|p| self.workspace_relative_display(p))
661                .collect::<Vec<_>>()
662                .join(", "),
663            self.missing_path_suggestion_suffix(path_str, PathSuggestionKind::File)
664        ))
665    }
666}
667
668#[cfg(test)]
669mod read_tests {
670    use super::*;
671    use crate::tools::grep_file::GrepSearchManager;
672    use std::fs;
673    use std::path::Path;
674    use tempfile::TempDir;
675
676    #[test]
677    fn history_jsonl_detection() {
678        assert!(is_history_jsonl(Path::new(
679            "/tmp/.vtcode/history/test.jsonl"
680        )));
681        assert!(!is_history_jsonl(Path::new(
682            "/tmp/.vtcode/history/test.txt"
683        )));
684        assert!(!is_history_jsonl(Path::new("/tmp/history/test.jsonl")));
685    }
686
687    #[test]
688    fn tool_output_spool_path_detection() {
689        assert!(is_tool_output_spool_path(Path::new(
690            ".vtcode/context/tool_outputs/run-123.txt"
691        )));
692        assert!(is_tool_output_spool_path(Path::new(
693            "/tmp/work/.vtcode/context/tool_outputs/run-123.txt"
694        )));
695        assert!(!is_tool_output_spool_path(Path::new(
696            ".vtcode/history/session.jsonl"
697        )));
698    }
699
700    #[test]
701    fn pty_session_id_detection_from_tool_output_path() {
702        assert_eq!(
703            pty_session_id_from_tool_output_path(Path::new(
704                ".vtcode/context/tool_outputs/run-658ceef2.txt"
705            )),
706            Some("run-658ceef2".to_string())
707        );
708        assert_eq!(
709            pty_session_id_from_tool_output_path(Path::new(
710                ".vtcode/context/tool_outputs/unified_exec_123.txt"
711            )),
712            None
713        );
714    }
715
716    #[test]
717    fn build_read_handler_args_normalizes_zero_bounds() {
718        let canonical = Path::new("/tmp/example.txt");
719        let args = json!({
720            "path": "example.txt",
721            "offset": 0,
722            "limit": 0
723        });
724
725        let built = build_read_handler_args(&args, canonical);
726
727        assert_eq!(built["file_path"], json!("/tmp/example.txt"));
728        assert_eq!(built["offset"], json!(1));
729        assert!(built.get("limit").is_none());
730    }
731
732    #[test]
733    fn build_read_handler_args_avoids_duplicate_path_aliases() {
734        let canonical = Path::new("/tmp/example.txt");
735        let args = json!({
736            "path": "example.txt"
737        });
738
739        let built = build_read_handler_args(&args, canonical);
740        let parsed: ReadFileArgs = serde_json::from_value(built).unwrap();
741
742        assert_eq!(parsed.file_path, "/tmp/example.txt");
743        assert_eq!(parsed.offset, 1);
744    }
745
746    #[test]
747    fn build_read_handler_args_normalizes_indentation() {
748        let canonical = Path::new("/tmp/example.txt");
749
750        let args_true = json!({
751            "path": "example.txt",
752            "indentation": true
753        });
754        let built_true = build_read_handler_args(&args_true, canonical);
755        assert_eq!(built_true["mode"], json!("indentation"));
756        assert_eq!(built_true["indentation"], json!({}));
757
758        let args_false = json!({
759            "path": "example.txt",
760            "indentation": false,
761            "mode": "slice"
762        });
763        let built_false = build_read_handler_args(&args_false, canonical);
764        assert!(built_false.get("indentation").is_none());
765        assert_eq!(built_false["mode"], json!("slice"));
766
767        let args_false_indent_mode = json!({
768            "path": "example.txt",
769            "indentation": false,
770            "mode": "indentation"
771        });
772        let built_false_indent_mode = build_read_handler_args(&args_false_indent_mode, canonical);
773        assert!(built_false_indent_mode.get("indentation").is_none());
774        assert_eq!(built_false_indent_mode["mode"], json!("slice"));
775
776        let args_obj = json!({
777            "path": "example.txt",
778            "indentation": { "max_levels": 2 }
779        });
780        let built_obj = build_read_handler_args(&args_obj, canonical);
781        assert_eq!(built_obj["mode"], json!("indentation"));
782        assert_eq!(built_obj["indentation"]["max_levels"], json!(2));
783
784        let args_true_str = json!({
785            "path": "example.txt",
786            "indentation": "true"
787        });
788        let built_true_str = build_read_handler_args(&args_true_str, canonical);
789        assert_eq!(built_true_str["mode"], json!("indentation"));
790        assert_eq!(built_true_str["indentation"], json!({}));
791
792        let args_false_str = json!({
793            "path": "example.txt",
794            "indentation": "false"
795        });
796        let built_false_str = build_read_handler_args(&args_false_str, canonical);
797        assert!(built_false_str.get("indentation").is_none());
798    }
799
800    #[test]
801    fn build_read_handler_args_normalizes_mode() {
802        let canonical = Path::new("/tmp/example.txt");
803
804        let args_empty = json!({
805            "path": "example.txt",
806            "mode": ""
807        });
808        let built_empty = build_read_handler_args(&args_empty, canonical);
809        assert_eq!(built_empty["mode"], json!("slice"));
810
811        let args_whitespace = json!({
812            "path": "example.txt",
813            "mode": "   "
814        });
815        let built_whitespace = build_read_handler_args(&args_whitespace, canonical);
816        assert_eq!(built_whitespace["mode"], json!("slice"));
817
818        let args_unknown = json!({
819            "path": "example.txt",
820            "mode": "unknown"
821        });
822        let built_unknown = build_read_handler_args(&args_unknown, canonical);
823        assert_eq!(built_unknown["mode"], json!("slice"));
824
825        let args_indent = json!({
826            "path": "example.txt",
827            "mode": "Indentation"
828        });
829        let built_indent = build_read_handler_args(&args_indent, canonical);
830        assert_eq!(built_indent["mode"], json!("indentation"));
831    }
832
833    #[test]
834    fn build_read_handler_args_maps_start_end_to_offset_limit() {
835        let canonical = Path::new("/tmp/example.txt");
836
837        // start_line/end_line -> offset/limit
838        let args = json!({
839            "path": "example.txt",
840            "start_line": 550,
841            "end_line": 590
842        });
843        let built = build_read_handler_args(&args, canonical);
844        assert_eq!(built["offset"], json!(550));
845        assert_eq!(built["limit"], json!(41));
846
847        // start_line only -> offset set, limit unchanged
848        let args_start_only = json!({
849            "path": "example.txt",
850            "start_line": 100
851        });
852        let built_start_only = build_read_handler_args(&args_start_only, canonical);
853        assert_eq!(built_start_only["offset"], json!(100));
854        assert!(built_start_only.get("limit").is_none());
855
856        // explicit offset takes precedence over start_line
857        let args_explicit = json!({
858            "path": "example.txt",
859            "offset": 10,
860            "start_line": 550,
861            "end_line": 590
862        });
863        let built_explicit = build_read_handler_args(&args_explicit, canonical);
864        assert_eq!(built_explicit["offset"], json!(10));
865        assert_eq!(built_explicit["limit"], json!(41));
866    }
867
868    #[test]
869    fn history_cache_key_varies_with_offset() {
870        let temp_dir = TempDir::new().unwrap();
871        let history_dir = temp_dir.path().join(".vtcode/history");
872        fs::create_dir_all(&history_dir).unwrap();
873        let file_path = history_dir.join("session_0001.jsonl");
874        fs::write(&file_path, "line1\nline2\n").unwrap();
875
876        let metadata = fs::metadata(&file_path).unwrap();
877        let key_a = build_history_cache_key(
878            &file_path,
879            &metadata,
880            &json!({"offset_lines": 0, "page_size_lines": 1}),
881        )
882        .unwrap();
883        let key_b = build_history_cache_key(
884            &file_path,
885            &metadata,
886            &json!({"offset_lines": 1, "page_size_lines": 1}),
887        )
888        .unwrap();
889
890        assert_ne!(key_a, key_b);
891    }
892
893    #[tokio::test]
894    async fn test_read_file_paging_lines() {
895        let temp_dir = TempDir::new().unwrap();
896        let workspace_root = temp_dir.path().to_path_buf();
897        let test_file = workspace_root.join("test_file.txt");
898
899        // Create test content with 10 lines
900        let test_content =
901            "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n";
902        fs::write(&test_file, test_content).unwrap();
903
904        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
905        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
906
907        // Test basic paging functionality: offset_lines=2, page_size_lines=3
908        // Should return lines 3, 4, 5 (0-indexed: 2, 3, 4)
909        let args = json!({
910            "path": test_file.to_string_lossy().into_owned(),
911            "offset_lines": 2,
912            "page_size_lines": 3
913        });
914
915        let result = file_ops.read_file(args).await.unwrap();
916        assert!(result["success"].as_bool().unwrap());
917        assert_eq!(result["content"].as_str().unwrap(), "line3\nline4\nline5");
918    }
919
920    #[tokio::test]
921    async fn test_read_file_paging_bytes() {
922        let temp_dir = TempDir::new().unwrap();
923        let workspace_root = temp_dir.path().to_path_buf();
924        let test_file = workspace_root.join("test_file.txt");
925
926        let test_content = "line1\nline2\nline3\nline4\nline5\n";
927        fs::write(&test_file, test_content).unwrap();
928
929        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
930        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
931
932        // Test byte-based paging: skip first 6 bytes ("line1\n") and read next 6 bytes
933        let args = json!({
934            "path": test_file.to_string_lossy().into_owned(),
935            "offset_bytes": 6,
936            "page_size_bytes": 6
937        });
938
939        let result = file_ops.read_file(args).await.unwrap();
940        assert!(result["success"].as_bool().unwrap());
941        assert_eq!(result["content"].as_str().unwrap(), "line2\n");
942    }
943
944    #[tokio::test]
945    async fn test_read_file_offset_beyond_size() {
946        let temp_dir = TempDir::new().unwrap();
947        let workspace_root = temp_dir.path().to_path_buf();
948        let test_file = workspace_root.join("test_file.txt");
949
950        let test_content = "line1\nline2\nline3\n";
951        fs::write(&test_file, test_content).unwrap();
952
953        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
954        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
955
956        // Test when offset is beyond file size
957        let args = json!({
958            "path": test_file.to_string_lossy().into_owned(),
959            "offset_lines": 100,
960            "page_size_lines": 10
961        });
962
963        let result = file_ops.read_file(args).await.unwrap();
964        assert!(result["success"].as_bool().unwrap());
965        assert_eq!(result["content"].as_str().unwrap(), "");
966    }
967
968    #[tokio::test]
969    async fn test_read_file_empty_file() {
970        let temp_dir = TempDir::new().unwrap();
971        let workspace_root = temp_dir.path().to_path_buf();
972        let test_file = workspace_root.join("empty_file.txt");
973
974        fs::write(&test_file, "").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 reading empty file with paging
980        let args = json!({
981            "path": test_file.to_string_lossy().into_owned(),
982            "offset_lines": 0,
983            "page_size_lines": 10
984        });
985
986        let result = file_ops.read_file(args).await.unwrap();
987        assert!(result["success"].as_bool().unwrap());
988        assert_eq!(result["content"].as_str().unwrap(), "");
989    }
990
991    #[tokio::test]
992    async fn test_read_file_legacy_functionality() {
993        let temp_dir = TempDir::new().unwrap();
994        let workspace_root = temp_dir.path().to_path_buf();
995        let test_file = workspace_root.join("test_file.txt");
996
997        let test_content = "line1\nline2\nline3\nline4\nline5\n";
998        fs::write(&test_file, test_content).unwrap();
999
1000        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1001        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1002
1003        // Test legacy functionality with max_bytes
1004        let args = json!({
1005            "path": test_file.to_string_lossy().into_owned(),
1006            "max_bytes": 10
1007        });
1008
1009        let result = file_ops.read_file(args).await.unwrap();
1010        assert!(result["success"].as_bool().unwrap());
1011        let content = result["content"].as_str().unwrap();
1012        assert!(content.len() <= 10);
1013        assert!(content.starts_with("line1"));
1014    }
1015
1016    #[tokio::test]
1017    async fn test_read_file_full_text_preserves_trailing_newline() {
1018        let temp_dir = TempDir::new().unwrap();
1019        let workspace_root = temp_dir.path().to_path_buf();
1020        let test_file = workspace_root.join("note.txt");
1021        fs::write(&test_file, "hello\n").unwrap();
1022
1023        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1024        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1025
1026        let result = file_ops
1027            .read_file(json!({ "path": "note.txt" }))
1028            .await
1029            .unwrap();
1030
1031        assert_eq!(result["content"].as_str(), Some("hello\n"));
1032    }
1033
1034    #[tokio::test]
1035    async fn test_read_file_legacy_token_chunking() {
1036        let temp_dir = TempDir::new().unwrap();
1037        let workspace_root = temp_dir.path().to_path_buf();
1038        let test_file = workspace_root.join("test_file.txt");
1039
1040        // Create test content with 50 lines
1041        let test_content = (1..=50)
1042            .map(|i| format!("line-{}", i))
1043            .collect::<Vec<_>>()
1044            .join("\n")
1045            + "\n";
1046        fs::write(&test_file, test_content).unwrap();
1047
1048        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1049        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1050
1051        // Token budget small enough to keep roughly first+last 5-10 lines
1052        let max_tokens = 15 * 12; // ~12 lines worth using TOKENS_PER_LINE
1053
1054        let args = json!({
1055            "path": test_file.to_string_lossy().into_owned(),
1056            "max_tokens": max_tokens
1057        });
1058
1059        let result = file_ops.read_file(args).await.unwrap();
1060        assert!(result["success"].as_bool().unwrap());
1061        let content = result["content"].as_str().unwrap();
1062        // Should contain first and last lines
1063        assert!(content.contains("line-1"));
1064        assert!(content.contains("line-50"));
1065        // Should indicate truncation
1066        assert!(result["is_truncated"].as_bool().unwrap());
1067        // Should report applied token budget
1068        assert_eq!(
1069            result["metadata"]["data"]["applied_max_tokens"]
1070                .as_u64()
1071                .unwrap(),
1072            max_tokens as u64
1073        );
1074    }
1075
1076    #[tokio::test]
1077    async fn test_read_file_patch_payload_returns_actionable_error() {
1078        let temp_dir = TempDir::new().unwrap();
1079        let workspace_root = temp_dir.path().to_path_buf();
1080        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1081        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1082
1083        let args = json!({
1084            "input": "*** Begin Patch\n*** End Patch\n"
1085        });
1086        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1087
1088        assert!(err.contains("Patch content was sent to read_file"));
1089        assert!(err.contains("\"action\":\"patch\""));
1090    }
1091
1092    #[tokio::test]
1093    async fn test_missing_spool_file_returns_actionable_error() {
1094        let temp_dir = TempDir::new().unwrap();
1095        let workspace_root = temp_dir.path().to_path_buf();
1096        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1097        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1098
1099        let args = json!({
1100            "path": ".vtcode/context/tool_outputs/unified_exec_123.txt"
1101        });
1102        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1103
1104        assert!(err.contains("Spool file not found"));
1105        assert!(err.contains("Re-run the original tool command"));
1106    }
1107
1108    #[tokio::test]
1109    async fn test_missing_run_session_file_suggests_unified_exec() {
1110        let temp_dir = TempDir::new().unwrap();
1111        let workspace_root = temp_dir.path().to_path_buf();
1112        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1113        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1114
1115        let args = json!({
1116            "path": ".vtcode/context/tool_outputs/run-123abc.txt"
1117        });
1118        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1119
1120        assert!(err.contains("Session output file not found"));
1121        assert!(err.contains("unified_exec"));
1122        assert!(err.contains("run-123abc"));
1123    }
1124
1125    #[tokio::test]
1126    async fn test_missing_file_suggests_similar_workspace_file() {
1127        let temp_dir = TempDir::new().unwrap();
1128        let workspace_root = temp_dir.path().to_path_buf();
1129        fs::create_dir_all(workspace_root.join("src")).unwrap();
1130        fs::write(workspace_root.join("src/tool_exec.rs"), "fn main() {}\n").unwrap();
1131        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1132        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1133
1134        let args = json!({
1135            "path": "src/tool_exe.rs"
1136        });
1137        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1138
1139        assert!(err.contains("Did you mean"));
1140        assert!(err.contains("src/tool_exec.rs"));
1141    }
1142
1143    #[tokio::test]
1144    async fn test_directory_path_returns_actionable_list_guidance() {
1145        let temp_dir = TempDir::new().unwrap();
1146        let workspace_root = temp_dir.path().to_path_buf();
1147        fs::create_dir_all(workspace_root.join("src/agent")).unwrap();
1148        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1149        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1150
1151        let args = json!({
1152            "path": "src"
1153        });
1154        let err = file_ops.read_file(args).await.unwrap_err().to_string();
1155
1156        assert!(err.contains("is a directory, not a file"));
1157        assert!(err.contains("action=\"list\""));
1158        assert!(err.contains("path=\"src\""));
1159    }
1160
1161    #[tokio::test]
1162    async fn test_spool_file_reads_are_chunked_with_next_offset() {
1163        let temp_dir = TempDir::new().unwrap();
1164        let workspace_root = temp_dir.path().to_path_buf();
1165        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1166        fs::create_dir_all(&spool_dir).unwrap();
1167        let spool_file = spool_dir.join("unified_exec_123.txt");
1168        let spool_content = (1..=120)
1169            .map(|i| format!("line{i}"))
1170            .collect::<Vec<_>>()
1171            .join("\n");
1172        fs::write(&spool_file, spool_content).unwrap();
1173
1174        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1175        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1176
1177        let args = json!({
1178            "path": ".vtcode/context/tool_outputs/unified_exec_123.txt"
1179        });
1180
1181        let result = file_ops.read_file(args).await.unwrap();
1182        assert_eq!(result["success"], true);
1183        assert_eq!(result["spool_chunked"], true);
1184        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
1185        assert_eq!(result["has_more"], true);
1186        assert_eq!(
1187            result["next_read_args"],
1188            json!({
1189                "path": ".vtcode/context/tool_outputs/unified_exec_123.txt",
1190                "offset": SPOOL_CHUNK_DEFAULT_LIMIT_LINES + 1,
1191                "limit": SPOOL_CHUNK_DEFAULT_LIMIT_LINES
1192            })
1193        );
1194        assert!(result.get("chunk_limit").is_none());
1195        assert!(result.get("next_offset").is_none());
1196        assert!(result.get("preferred_next_action").is_none());
1197        assert!(result.get("follow_up_prompt").is_none());
1198    }
1199
1200    #[tokio::test]
1201    async fn test_spool_file_last_chunk_omits_continuation_fields() {
1202        let temp_dir = TempDir::new().unwrap();
1203        let workspace_root = temp_dir.path().to_path_buf();
1204        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1205        fs::create_dir_all(&spool_dir).unwrap();
1206        let spool_file = spool_dir.join("unified_exec_999.txt");
1207        let spool_content = (1..=5)
1208            .map(|i| format!("line{i}"))
1209            .collect::<Vec<_>>()
1210            .join("\n");
1211        fs::write(&spool_file, spool_content).unwrap();
1212
1213        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1214        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1215
1216        let args = json!({
1217            "path": ".vtcode/context/tool_outputs/unified_exec_999.txt"
1218        });
1219
1220        let result = file_ops.read_file(args).await.unwrap();
1221        assert_eq!(result["success"], true);
1222        assert_eq!(result["spool_chunked"], true);
1223        assert_eq!(result["lines_returned"], 5);
1224        assert!(result.get("has_more").is_none());
1225        assert!(result.get("next_read_args").is_none());
1226        assert!(result.get("follow_up_prompt").is_none());
1227        assert!(result.get("chunk_limit").is_none());
1228        assert!(result.get("next_offset").is_none());
1229        assert!(result.get("preferred_next_action").is_none());
1230    }
1231
1232    #[tokio::test]
1233    async fn test_spool_file_exact_limit_at_eof_omits_continuation_fields() {
1234        let temp_dir = TempDir::new().unwrap();
1235        let workspace_root = temp_dir.path().to_path_buf();
1236        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1237        fs::create_dir_all(&spool_dir).unwrap();
1238        let spool_file = spool_dir.join("unified_exec_exact.txt");
1239        let spool_content = (1..=SPOOL_CHUNK_DEFAULT_LIMIT_LINES)
1240            .map(|i| format!("line{i}"))
1241            .collect::<Vec<_>>()
1242            .join("\n");
1243        fs::write(&spool_file, spool_content).unwrap();
1244
1245        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1246        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1247
1248        let result = file_ops
1249            .read_file(json!({
1250                "path": ".vtcode/context/tool_outputs/unified_exec_exact.txt"
1251            }))
1252            .await
1253            .unwrap();
1254
1255        assert_eq!(result["success"], true);
1256        assert_eq!(result["spool_chunked"], true);
1257        assert_eq!(result["lines_returned"], SPOOL_CHUNK_DEFAULT_LIMIT_LINES);
1258        assert!(result.get("has_more").is_none());
1259        assert!(result.get("next_read_args").is_none());
1260    }
1261
1262    #[tokio::test]
1263    async fn test_read_file_accepts_compact_spool_continuation_args() {
1264        let temp_dir = TempDir::new().unwrap();
1265        let workspace_root = temp_dir.path().to_path_buf();
1266        let spool_dir = workspace_root.join(".vtcode/context/tool_outputs");
1267        fs::create_dir_all(&spool_dir).unwrap();
1268        let spool_file = spool_dir.join("unified_exec_456.txt");
1269        let spool_content = (1..=120)
1270            .map(|i| format!("line{i}"))
1271            .collect::<Vec<_>>()
1272            .join("\n");
1273        fs::write(&spool_file, spool_content).unwrap();
1274
1275        let grep_manager = std::sync::Arc::new(GrepSearchManager::new(workspace_root.clone()));
1276        let file_ops = FileOpsTool::new(workspace_root, grep_manager);
1277
1278        let result = file_ops
1279            .read_file(json!({
1280                "p": ".vtcode/context/tool_outputs/unified_exec_456.txt",
1281                "o": 81,
1282                "l": 40
1283            }))
1284            .await
1285            .unwrap();
1286
1287        assert_eq!(result["success"], true);
1288        assert_eq!(
1289            result["path"],
1290            ".vtcode/context/tool_outputs/unified_exec_456.txt"
1291        );
1292        assert_eq!(result["spool_chunked"], true);
1293        assert_eq!(result["lines_returned"], 40);
1294        assert_eq!(
1295            result["content"].as_str().unwrap().lines().next(),
1296            Some("line81")
1297        );
1298        assert!(result.get("has_more").is_none());
1299        assert!(result.get("next_read_args").is_none());
1300    }
1301}