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(content: &str, effective_old_str: &str, effective_new_str: &str) -> Option<String> {
107 let had_trailing_newline = content.ends_with('\n');
108 let mut replacement_occurred = false;
109 let mut new_content = content.to_owned();
110
111 if let Some(pos) = content.find(effective_old_str) {
112 new_content = format!("{}{}{}", &content[..pos], effective_new_str, &content[pos + effective_old_str.len()..]);
113 replacement_occurred = new_content != content;
114 }
115
116 if !replacement_occurred {
117 let old_lines: Vec<&str> = effective_old_str.lines().collect();
118 let content_lines: Vec<&str> = content.lines().collect();
119 let replacement_lines: Vec<&str> = effective_new_str.lines().collect();
120
121 'outer: for (i, window) in content_lines.windows(old_lines.len()).enumerate() {
122 if utils::lines_match(window, &old_lines) {
123 let mut result_lines = Vec::with_capacity(
124 i + replacement_lines.len() + content_lines.len().saturating_sub(i + old_lines.len()),
125 );
126 result_lines.extend_from_slice(&content_lines[..i]);
127 result_lines.extend_from_slice(&replacement_lines);
128 result_lines.extend_from_slice(&content_lines[i + old_lines.len()..]);
129
130 new_content = result_lines.join("\n");
131 replacement_occurred = true;
132 break 'outer;
133 }
134 }
135
136 if !replacement_occurred {
137 let old_normalized: Vec<String> = old_lines.iter().map(|l| ws_normalize(l)).collect();
139
140 for (i, window) in content_lines.windows(old_lines.len()).enumerate() {
141 let mut ok = true;
142 for (j, line) in window.iter().enumerate() {
143 if ws_normalize(line) != old_normalized[j] {
144 ok = false;
145 break;
146 }
147 }
148
149 if ok {
150 let mut result_lines = Vec::with_capacity(
151 i + replacement_lines.len() + content_lines.len().saturating_sub(i + old_lines.len()),
152 );
153 result_lines.extend_from_slice(&content_lines[..i]);
154 result_lines.extend_from_slice(&replacement_lines);
155 result_lines.extend_from_slice(&content_lines[i + old_lines.len()..]);
156
157 new_content = result_lines.join("\n");
158 replacement_occurred = true;
159 break;
160 }
161 }
162 }
163 }
164
165 if !replacement_occurred {
166 return None;
167 }
168
169 if had_trailing_newline && !new_content.ends_with('\n') {
170 new_content.push('\n');
171 }
172
173 Some(new_content)
174}
175
176#[cold]
177fn edit_not_found_error(
178 current_content: &str,
179 effective_old_str: &str,
180 stripped_old: bool,
181 stripped_new: bool,
182) -> anyhow::Error {
183 let content_preview = if current_content.len() > 500 {
184 vtcode_commons::preview::condense_text_bytes(current_content, 250, 250)
185 } else {
186 current_content.to_owned()
187 };
188 let old_preview = vtcode_commons::preview::condense_error_echo(effective_old_str);
190
191 let numbering_note = if stripped_old || stripped_new {
192 "\n\nNote: line-number prefixes were stripped before matching."
193 } else {
194 ""
195 };
196
197 anyhow!(
198 "Could not find text to replace in file.\n\nExpected to replace:\n{old_preview}\n\nFile content preview:\n{content_preview}\n\nFix: old_str must match the file content exactly, including whitespace, newlines, and indentation. Re-read the target lines with `exec_command` to get the exact text, then copy it into old_str without adding newlines or changing indentation.{numbering_note}"
199 )
200}
201
202impl ToolRegistry {
203 pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
206 self.execute_tool(tools::READ_FILE, args)
207 }
208
209 pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
211 self.execute_tool(tools::WRITE_FILE, args)
212 }
213
214 pub fn create_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
216 self.execute_tool(tools::CREATE_FILE, args)
217 }
218
219 pub async fn edit_file(&self, args: Value) -> Result<Value> {
220 let input: EditInput = deserialize_tool_args(&args, "edit_file")?;
221 let override_snapshot = conflict_override_snapshot(&args);
222
223 let (effective_old_str, stripped_old) = strip_line_prefixes(&input.old_str);
224 let (effective_new_str, stripped_new) = strip_line_prefixes(&input.new_str);
225
226 let old_len = effective_old_str.len();
227 let new_len = effective_new_str.len();
228 let old_lines = effective_old_str.lines().count();
229 let new_lines = effective_new_str.lines().count();
230
231 if old_len > EDIT_FILE_MAX_CHARS
232 || new_len > EDIT_FILE_MAX_CHARS
233 || old_lines > EDIT_FILE_MAX_LINES
234 || new_lines > EDIT_FILE_MAX_LINES
235 {
236 let guidance = crate::tools::error_helpers::USE_PATCH_FOR_LARGE_EDITS;
237 return Err(anyhow!(
238 "edit_file is limited to small literal replacements (≤ {EDIT_FILE_MAX_LINES} lines or ≤ {EDIT_FILE_MAX_CHARS} characters). {guidance}",
239 ));
240 }
241
242 let requested_path = PathBuf::from(&input.path);
243 let canonical_path = resolve_workspace_path(self.workspace_root(), &requested_path)
244 .with_context(|| format!("Failed to resolve path: {}", requested_path.display()))?;
245 let _mutation_lease = self.edited_file_monitor_ref().acquire_mutation(&canonical_path).await;
246
247 let metadata = fs::metadata(&canonical_path)
248 .await
249 .with_context(|| format!("Cannot read file metadata: {}", canonical_path.display()))?;
250 if metadata.len() > EDIT_FILE_MAX_BYTES {
251 let actual = metadata.len();
252 let max = EDIT_FILE_MAX_BYTES;
253 let guidance = crate::tools::error_helpers::USE_PATCH_OR_WRITE_FOR_LARGE_FILES;
254 return Err(anyhow!("File too large for edit_file: {actual} bytes (max: {max} bytes). {guidance}",));
255 }
256
257 let intended_content = self
258 .edited_file_monitor_ref()
259 .tracked_read_text(&canonical_path)
260 .await
261 .and_then(|content| apply_edit_replacement(&content, &effective_old_str, &effective_new_str));
262
263 if let Some(conflict) = self
264 .edited_file_monitor_ref()
265 .detect_conflict(&canonical_path, intended_content, override_snapshot.clone())
266 .await?
267 {
268 return Ok(conflict.to_tool_output(self.workspace_root()));
269 }
270
271 let current_content = fs::read_to_string(&canonical_path)
272 .await
273 .with_context(|| format!("Cannot read file: {}", canonical_path.display()))?;
274 let Some(new_content) = apply_edit_replacement(¤t_content, &effective_old_str, &effective_new_str) else {
275 return Err(edit_not_found_error(¤t_content, &effective_old_str, stripped_old, stripped_new));
276 };
277
278 let mut write_args = json!({
279 "path": input.path,
280 "content": new_content,
281 "mode": "overwrite"
282 });
283 if let Some(snapshot) = args.get(FILE_CONFLICT_OVERRIDE_ARG) {
284 write_args[FILE_CONFLICT_OVERRIDE_ARG] = snapshot.clone();
285 }
286
287 self.file_ops_tool().write_file_internal(write_args, false).await
288 }
289
290 pub fn delete_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
292 self.execute_tool(tools::DELETE_FILE, args)
293 }
294
295 pub async fn grep_file(&self, args: Value) -> Result<Value> {
296 let mut payload = args;
297 if let Some(obj) = payload.as_object_mut() {
298 let has_path = obj
299 .get("path")
300 .and_then(|value| value.as_str())
301 .map(str::trim)
302 .is_some_and(|path| !path.is_empty());
303 if !has_path {
304 obj.insert("path".to_string(), json!("."));
305 }
306 }
307
308 let input = serde_json::from_value(payload)?;
309 self.grep_file_manager().perform_search(input).await.map(|result| json!(result))
310 }
311
312 pub fn last_grep_file_result(&self) -> Option<GrepSearchResult> {
313 self.grep_file_manager().last_result()
314 }
315
316 pub async fn list_files(&self, args: Value) -> Result<Value> {
317 let tool = self.inventory.file_ops_tool().clone();
320 tool.execute(args).await
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::edit_not_found_error;
327
328 #[test]
329 fn edit_not_found_error_points_at_a_model_facing_reread_path() {
330 let message = edit_not_found_error("fn main() {}\n", "fn mian() {}", false, false).to_string();
331
332 assert!(message.contains(
333 "Re-read the target lines with `exec_command` to get the exact text, then copy it into old_str without adding newlines or changing indentation."
334 ));
335 assert!(!message.contains("read_file"));
336 assert!(!message.contains("line-number prefixes were stripped"));
337 }
338
339 #[test]
340 fn edit_not_found_error_condenses_large_old_str() {
341 let old_str = "x".repeat(4_000);
342 let message = edit_not_found_error("short file\n", &old_str, false, false).to_string();
343
344 assert!(message.len() < 2_500, "error must not re-echo the full old_str, got {} bytes", message.len());
345 assert!(message.contains('x'), "kept a disambiguating head/tail of old_str");
346 assert!(
347 message.contains("omitted") || message.contains("…") || message.contains("..."),
348 "condensed marker present"
349 );
350 }
351}