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