vtcode_core/tools/file_ops/
mod.rs1mod 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
26pub(crate) const MAX_LINE_READ_BYTES: usize = 64 * 1024;
33
34pub(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 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
78pub(crate) struct ByteRangeReadResult {
80 pub content: String,
81 pub has_more: bool,
82 pub lines_read: usize,
84}
85
86pub(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 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 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 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 return Ok(ByteRangeReadResult { content: raw, has_more, lines_read: 0 });
143 }
144
145 let start_line = count_lines_before(file_path, offset_bytes).await?;
147
148 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 if i == 0 && !at_line_boundary && !line.is_empty() {
172 current_line += 1;
173 continue;
174 }
175
176 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
194async 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; 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
226pub(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}