Skip to main content

vtcode_core/tools/file_ops/
mod.rs

1//! File operation tools with composable functionality.
2//!
3//! This module provides the `FileOpsTool` for file discovery and listing operations,
4//! along with supporting utilities for diff previews and path helpers.
5
6mod diff_preview;
7mod list;
8mod path_policy;
9mod read;
10pub(crate) use read::{PATCH_READ_CACHE_NONCE, bounded_line_read_path};
11mod tool;
12mod write;
13
14use anyhow::{Context, Result};
15use std::path::Path;
16use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader};
17use vtcode_commons::async_utils::read_exact_uninit;
18
19pub use diff_preview::{
20    SUPPRESSED_PREVIEW_REASON, build_diff_preview, canonical_diff_previews, diff_output_has_effective_change,
21    diff_preview_error_skip, diff_preview_size_skip, diff_preview_suppressed, diff_preview_user_message,
22};
23pub use tool::FileOpsTool;
24pub use vtcode_commons::fs::is_image_path;
25
26/// Hard cap on the byte size of a single logical line read by any file tool.
27///
28/// `read_until(b'\n')` grows the caller's buffer unboundedly when a file
29/// contains a huge line without a newline (e.g. minified bundles). A line
30/// longer than this cap is truncated at the cap for display purposes; the rest
31/// of the line is drained so the stream stays aligned at the next newline.
32pub(crate) const MAX_LINE_READ_BYTES: usize = 64 * 1024;
33
34/// Read the next line into `buffer`, bounded at `MAX_LINE_READ_BYTES`.
35///
36/// Returns `Ok(None)` at EOF, otherwise `Ok(Some(true))` when the line was
37/// truncated at the cap (the remainder of the physical line was drained), or
38/// `Ok(Some(false))` for a fully-read line. The trailing `\n`/`\r\n` is kept in
39/// `buffer` for callers that already strip it.
40pub(crate) async fn read_bounded_line<R: AsyncBufRead + Unpin>(
41    reader: &mut R,
42    buffer: &mut Vec<u8>,
43) -> Result<Option<bool>> {
44    buffer.clear();
45    let mut limited = reader.take(MAX_LINE_READ_BYTES as u64 + 1);
46    let n = limited.read_until(b'\n', buffer).await.context("failed to read line")?;
47    if n == 0 {
48        return Ok(None);
49    }
50
51    let truncated = buffer.last() != Some(&b'\n') && buffer.len() > MAX_LINE_READ_BYTES;
52    if truncated {
53        // Drain the remainder of the oversized physical line so subsequent
54        // reads start at the next newline. Read in bounded chunks through a
55        // `Take` so the drain never allocates the full remainder AND never
56        // over-consumes past the newline (a plain `read` would swallow the
57        // next line that BufReader prefetched into the same chunk).
58        let mut drain_buf = Vec::with_capacity(4096);
59        loop {
60            drain_buf.clear();
61            let mut chunk = reader.take(4096);
62            let n = chunk
63                .read_until(b'\n', &mut drain_buf)
64                .await
65                .context("failed to drain oversized line")?;
66            if n == 0 {
67                break;
68            }
69            if drain_buf.last() == Some(&b'\n') {
70                break;
71            }
72        }
73        buffer.truncate(MAX_LINE_READ_BYTES);
74    }
75    Ok(Some(truncated))
76}
77
78/// Result of a byte-range read operation.
79pub(crate) struct ByteRangeReadResult {
80    pub content: String,
81    pub has_more: bool,
82    /// Number of lines in the returned content.
83    pub lines_read: usize,
84}
85
86/// Read a byte range from a file using seek-based access.
87///
88/// When `line_numbers` is true, aligns to line boundaries (skips partial first line)
89/// and prefixes each line with its 1-indexed line number (e.g. "42: content").
90/// When false, returns raw bytes as a UTF-8 string without line alignment.
91pub(crate) async fn read_byte_range(
92    file_path: &Path,
93    offset_bytes: u64,
94    page_size_bytes: usize,
95    line_numbers: bool,
96) -> Result<ByteRangeReadResult> {
97    let metadata = tokio::fs::metadata(file_path)
98        .await
99        .with_context(|| format!("Failed to read metadata for: {}", file_path.display()))?;
100
101    if !metadata.is_file() {
102        anyhow::bail!("Path is not a file: {}", file_path.display());
103    }
104
105    let file_size = metadata.len();
106
107    // Offset beyond file boundary: return empty
108    if offset_bytes >= file_size {
109        return Ok(ByteRangeReadResult {
110            content: String::new(),
111            has_more: false,
112            lines_read: 0,
113        });
114    }
115
116    // Open file and seek to offset
117    let mut file = tokio::fs::File::open(file_path)
118        .await
119        .with_context(|| format!("Failed to open: {}", file_path.display()))?;
120
121    file.seek(std::io::SeekFrom::Start(offset_bytes))
122        .await
123        .with_context(|| format!("Failed to seek to offset {} in: {}", offset_bytes, file_path.display()))?;
124
125    // Clamp read size to file bounds, guarding against overflow
126    let page_size_u64 = page_size_bytes as u64;
127    let end_pos = offset_bytes
128        .checked_add(page_size_u64)
129        .map(|end| std::cmp::min(end, file_size))
130        .unwrap_or(file_size);
131    let actual_read_size = (end_pos - offset_bytes) as usize;
132
133    let buffer = read_exact_uninit(&mut file, actual_read_size).await.with_context(|| {
134        format!("Failed to read {} bytes from offset {} in: {}", actual_read_size, offset_bytes, file_path.display())
135    })?;
136
137    let raw = String::from_utf8_lossy(&buffer).into_owned();
138    let has_more = end_pos < file_size;
139
140    if !line_numbers {
141        // Raw mode: return content without line numbers or boundary alignment
142        return Ok(ByteRangeReadResult { content: raw, has_more, lines_read: 0 });
143    }
144
145    // Line-number mode: align to line boundaries and add line numbers
146    let start_line = count_lines_before(file_path, offset_bytes).await?;
147
148    // Check if we're at a line boundary by looking at the byte before offset
149    let at_line_boundary = if offset_bytes == 0 {
150        true
151    } else {
152        let mut pre = tokio::fs::File::open(file_path)
153            .await
154            .with_context(|| format!("Failed to open: {}", file_path.display()))?;
155        pre.seek(std::io::SeekFrom::Start(offset_bytes - 1))
156            .await
157            .context("failed to seek for boundary check")?;
158        let mut prev_byte = [0u8; 1];
159        AsyncReadExt::read_exact(&mut pre, &mut prev_byte)
160            .await
161            .context("failed to read boundary byte")?;
162        prev_byte[0] == b'\n'
163    };
164
165    let lines: Vec<&str> = raw.split('\n').collect();
166    let mut formatted_lines = Vec::new();
167    let mut current_line = start_line;
168
169    for (i, line) in lines.iter().enumerate() {
170        // Skip partial first line if we're not at a line boundary
171        if i == 0 && !at_line_boundary && !line.is_empty() {
172            current_line += 1;
173            continue;
174        }
175
176        // Skip empty trailing line from split if has_more (incomplete last line)
177        if i == lines.len() - 1 && has_more && line.is_empty() {
178            continue;
179        }
180
181        let trimmed = line.trim_end_matches('\r');
182        if !trimmed.is_empty() || i < lines.len() - 1 {
183            formatted_lines.push(format!("{current_line}: {trimmed}"));
184            current_line += 1;
185        }
186    }
187
188    let lines_read = formatted_lines.len();
189    let content = formatted_lines.join("\n");
190
191    Ok(ByteRangeReadResult { content, has_more, lines_read })
192}
193
194/// Count the number of newlines before the given byte offset to determine
195/// the 1-indexed line number at that position.
196async fn count_lines_before(file_path: &Path, offset_bytes: u64) -> Result<usize> {
197    if offset_bytes == 0 {
198        return Ok(1);
199    }
200
201    let file = tokio::fs::File::open(file_path)
202        .await
203        .with_context(|| format!("Failed to open: {}", file_path.display()))?;
204
205    let mut reader = BufReader::new(file);
206    let mut line_count: usize = 1; // 1-indexed
207    let mut bytes_read: u64 = 0;
208    let mut buffer = Vec::new();
209
210    while bytes_read < offset_bytes {
211        let n = read_bounded_line(&mut reader, &mut buffer)
212            .await
213            .context("failed to count lines")?;
214
215        let Some(_) = n else { break };
216
217        bytes_read += buffer.len() as u64;
218        if bytes_read <= offset_bytes {
219            line_count += 1;
220        }
221    }
222
223    Ok(line_count)
224}
225
226/// Restore exact text content by accounting for trailing newline differences.
227///
228/// When a file's byte size is slightly larger than the content string length,
229/// this reconstructs the original content by appending the missing trailing
230/// newline(s). Returns `None` if the size mismatch is unexpected.
231pub(crate) fn restore_exact_text_content(content: &str, size_bytes: u64) -> Option<String> {
232    let content_size = content.len() as u64;
233    match size_bytes.checked_sub(content_size) {
234        Some(0) => Some(content.to_string()),
235        Some(1) => Some(format!("{content}\n")),
236        Some(2) if content.contains("\r\n") || !content.contains('\n') => Some(format!("{content}\r\n")),
237        _ => None,
238    }
239}