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 if let Some(src) = obj.get("length").cloned() {
154 obj.entry("page_size_bytes".to_string()).or_insert(src);
155 }
156
157 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
179pub(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 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 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 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 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 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 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 let input: Input = serde_json::from_value(args.clone())
603 .context("Error: Invalid 'read_file' arguments for legacy handler.")?;
604
605 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let max_tokens = 15 * 12; 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 assert!(content.contains("line-1"));
1234 assert!(content.contains("line-50"));
1235 assert!(result["is_truncated"].as_bool().unwrap());
1237 #[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; 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 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 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 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 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 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 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 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}