vtcode_core/tools/registry/
file_helpers.rs1use anyhow::{Context, Result, anyhow};
8use serde_json::{Value, json};
9use std::future::Future;
10use std::path::PathBuf;
11use tokio::fs;
12
13use crate::utils::path::resolve_workspace_path;
14
15use crate::config::constants::tools;
16use crate::tools::edited_file_monitor::{FILE_CONFLICT_OVERRIDE_ARG, conflict_override_snapshot};
17use crate::tools::error_helpers::deserialize_tool_args;
18use crate::tools::grep_file::GrepSearchResult;
19use crate::tools::traits::Tool;
20use crate::tools::types::EditInput;
21
22use super::ToolRegistry;
23use super::utils;
24
25const EDIT_FILE_MAX_CHARS: usize = 800;
26const EDIT_FILE_MAX_LINES: usize = 40;
27const EDIT_FILE_MAX_BYTES: u64 = 1_048_576;
28
29fn line_prefix_len(line: &str) -> Option<usize> {
30 let bytes = line.as_bytes();
31 if bytes.is_empty() {
32 return None;
33 }
34
35 let mut i = 0;
36 if bytes[i] == b'L' {
37 i += 1;
38 }
39
40 let start_digits = i;
41 while i < bytes.len() && bytes[i].is_ascii_digit() {
42 i += 1;
43 }
44
45 if i == start_digits || i >= bytes.len() || bytes[i] != b':' {
46 return None;
47 }
48
49 i += 1;
50 if i < bytes.len() && bytes[i] == b' ' {
51 i += 1;
52 }
53
54 Some(i)
55}
56
57fn strip_line_prefixes(text: &str) -> (String, bool) {
58 let lines: Vec<&str> = text.lines().collect();
59 if lines.is_empty() {
60 return (text.to_string(), false);
61 }
62
63 let mut has_prefix = false;
64 let mut all_prefixed = true;
65
66 for line in &lines {
67 if line.trim().is_empty() {
68 continue;
69 }
70
71 if line_prefix_len(line).is_some() {
72 has_prefix = true;
73 } else {
74 all_prefixed = false;
75 }
76 }
77
78 if !has_prefix || !all_prefixed {
79 return (text.to_string(), false);
80 }
81
82 let stripped = lines
83 .iter()
84 .map(|line| match line_prefix_len(line) {
85 Some(prefix_len) => &line[prefix_len..],
86 None => *line,
87 })
88 .collect::<Vec<_>>()
89 .join("\n");
90
91 (stripped, true)
92}
93
94fn ws_normalize(s: &str) -> String {
96 let mut result = String::with_capacity(s.len());
97 for word in s.split_whitespace() {
98 if !result.is_empty() {
99 result.push(' ');
100 }
101 result.push_str(word);
102 }
103 result
104}
105
106fn apply_edit_replacement(
107 content: &str,
108 effective_old_str: &str,
109 effective_new_str: &str,
110) -> Option<String> {
111 let had_trailing_newline = content.ends_with('\n');
112 let mut replacement_occurred = false;
113 let mut new_content = content.to_owned();
114
115 if content.contains(effective_old_str) {
116 new_content = content.replace(effective_old_str, effective_new_str);
117 replacement_occurred = new_content != content;
118 }
119
120 if !replacement_occurred {
121 let old_lines: Vec<&str> = effective_old_str.lines().collect();
122 let content_lines: Vec<&str> = content.lines().collect();
123 let replacement_lines: Vec<&str> = effective_new_str.lines().collect();
124
125 'outer: for (i, window) in content_lines.windows(old_lines.len()).enumerate() {
126 if utils::lines_match(window, &old_lines) {
127 let mut result_lines = Vec::with_capacity(
128 i + replacement_lines.len()
129 + content_lines.len().saturating_sub(i + old_lines.len()),
130 );
131 result_lines.extend_from_slice(&content_lines[..i]);
132 result_lines.extend_from_slice(&replacement_lines);
133 result_lines.extend_from_slice(&content_lines[i + old_lines.len()..]);
134
135 new_content = result_lines.join("\n");
136 replacement_occurred = true;
137 break 'outer;
138 }
139 }
140
141 if !replacement_occurred {
142 let old_normalized: Vec<String> = old_lines.iter().map(|l| ws_normalize(l)).collect();
144
145 for (i, window) in content_lines.windows(old_lines.len()).enumerate() {
146 let mut ok = true;
147 for (j, line) in window.iter().enumerate() {
148 if ws_normalize(line) != old_normalized[j] {
149 ok = false;
150 break;
151 }
152 }
153
154 if ok {
155 let mut result_lines = Vec::with_capacity(
156 i + replacement_lines.len()
157 + content_lines.len().saturating_sub(i + old_lines.len()),
158 );
159 result_lines.extend_from_slice(&content_lines[..i]);
160 result_lines.extend_from_slice(&replacement_lines);
161 result_lines.extend_from_slice(&content_lines[i + old_lines.len()..]);
162
163 new_content = result_lines.join("\n");
164 replacement_occurred = true;
165 break;
166 }
167 }
168 }
169 }
170
171 if !replacement_occurred {
172 return None;
173 }
174
175 if had_trailing_newline && !new_content.ends_with('\n') {
176 new_content.push('\n');
177 }
178
179 Some(new_content)
180}
181
182#[cold]
183fn edit_not_found_error(
184 current_content: &str,
185 effective_old_str: &str,
186 stripped_old: bool,
187 stripped_new: bool,
188) -> anyhow::Error {
189 let content_preview = if current_content.len() > 500 {
190 vtcode_commons::preview::condense_text_bytes(current_content, 250, 250)
191 } else {
192 current_content.to_owned()
193 };
194
195 let numbering_note = if stripped_old || stripped_new {
196 "\n\nNote: line-number prefixes were stripped before matching."
197 } else {
198 ""
199 };
200
201 anyhow!(
202 "Could not find text to replace in file.\n\nExpected to replace:\n{}\n\nFile content preview:\n{}\n\nFix: The old_str must EXACTLY match the file content including all whitespace and newlines. Use read_file first to get the exact text, then copy it precisely into old_str. Do NOT add extra newlines or change indentation.{}",
203 effective_old_str,
204 content_preview,
205 numbering_note
206 )
207}
208
209impl ToolRegistry {
210 pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
213 self.execute_tool(tools::READ_FILE, args)
214 }
215
216 pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
218 self.execute_tool(tools::WRITE_FILE, args)
219 }
220
221 pub fn create_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
223 self.execute_tool(tools::CREATE_FILE, args)
224 }
225
226 pub async fn edit_file(&self, args: Value) -> Result<Value> {
227 let input: EditInput = deserialize_tool_args(&args, "edit_file")?;
228 let override_snapshot = conflict_override_snapshot(&args);
229
230 let (effective_old_str, stripped_old) = strip_line_prefixes(&input.old_str);
231 let (effective_new_str, stripped_new) = strip_line_prefixes(&input.new_str);
232
233 let old_len = effective_old_str.len();
234 let new_len = effective_new_str.len();
235 let old_lines = effective_old_str.lines().count();
236 let new_lines = effective_new_str.lines().count();
237
238 if old_len > EDIT_FILE_MAX_CHARS
239 || new_len > EDIT_FILE_MAX_CHARS
240 || old_lines > EDIT_FILE_MAX_LINES
241 || new_lines > EDIT_FILE_MAX_LINES
242 {
243 return Err(anyhow!(
244 "edit_file is limited to small literal replacements (≤ {lines} lines or ≤ {chars} characters). Use apply_patch for larger or multi-file edits.",
245 lines = EDIT_FILE_MAX_LINES,
246 chars = EDIT_FILE_MAX_CHARS,
247 ));
248 }
249
250 let requested_path = PathBuf::from(&input.path);
251 let canonical_path = resolve_workspace_path(self.workspace_root(), &requested_path)
252 .with_context(|| format!("Failed to resolve path: {}", requested_path.display()))?;
253 let _mutation_lease = self
254 .edited_file_monitor_ref()
255 .acquire_mutation(&canonical_path)
256 .await;
257
258 let metadata = fs::metadata(&canonical_path)
259 .await
260 .with_context(|| format!("Cannot read file metadata: {}", canonical_path.display()))?;
261 if metadata.len() > EDIT_FILE_MAX_BYTES {
262 return Err(anyhow!(
263 "File too large for edit_file: {} bytes (max: {} bytes)",
264 metadata.len(),
265 EDIT_FILE_MAX_BYTES
266 ));
267 }
268
269 let intended_content = self
270 .edited_file_monitor_ref()
271 .tracked_read_text(&canonical_path)
272 .await
273 .and_then(|content| {
274 apply_edit_replacement(&content, &effective_old_str, &effective_new_str)
275 });
276
277 if let Some(conflict) = self
278 .edited_file_monitor_ref()
279 .detect_conflict(&canonical_path, intended_content, override_snapshot.clone())
280 .await?
281 {
282 return Ok(conflict.to_tool_output(self.workspace_root()));
283 }
284
285 let current_content = fs::read_to_string(&canonical_path)
286 .await
287 .with_context(|| format!("Cannot read file: {}", canonical_path.display()))?;
288 let Some(new_content) =
289 apply_edit_replacement(¤t_content, &effective_old_str, &effective_new_str)
290 else {
291 return Err(edit_not_found_error(
292 ¤t_content,
293 &effective_old_str,
294 stripped_old,
295 stripped_new,
296 ));
297 };
298
299 let mut write_args = json!({
300 "path": input.path,
301 "content": new_content,
302 "mode": "overwrite"
303 });
304 if let Some(snapshot) = args.get(FILE_CONFLICT_OVERRIDE_ARG) {
305 write_args[FILE_CONFLICT_OVERRIDE_ARG] = snapshot.clone();
306 }
307
308 self.file_ops_tool()
309 .write_file_internal(write_args, false)
310 .await
311 }
312
313 pub fn delete_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
315 self.execute_tool(tools::DELETE_FILE, args)
316 }
317
318 pub async fn grep_file(&self, args: Value) -> Result<Value> {
319 let mut payload = args;
320 if let Some(obj) = payload.as_object_mut() {
321 obj.entry("action".to_string())
322 .or_insert_with(|| json!("grep"));
323 }
324 self.execute_tool(tools::UNIFIED_SEARCH, payload).await
325 }
326
327 pub fn last_grep_file_result(&self) -> Option<GrepSearchResult> {
328 self.grep_file_manager().last_result()
329 }
330
331 pub async fn list_files(&self, args: Value) -> Result<Value> {
332 let tool = self.inventory.file_ops_tool().clone();
335 tool.execute(args).await
336 }
337}