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