Skip to main content

vtcode_core/tools/registry/
file_helpers.rs

1//! File operation helpers and the edit_file tool
2//!
3//! This module provides convenience methods for common file operations and implements
4//! the `edit_file` tool, which is optimized for small, surgical edits (≤800 chars, ≤40 lines).
5//! For larger or multi-file changes, use `apply_patch` instead.
6
7use 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
94/// Normalize internal whitespace: collapse consecutive whitespace to single spaces.
95fn 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            // Pre-compute normalised old lines once (avoid re-allocation per window).
143            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    /// Inline-delegating wrapper that returns the inner future directly to
211    /// avoid an extra coroutine state machine (audit section 16).
212    pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
213        self.execute_tool(tools::READ_FILE, args)
214    }
215
216    /// Inline-delegating wrapper. See [`Self::read_file`].
217    pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
218        self.execute_tool(tools::WRITE_FILE, args)
219    }
220
221    /// Inline-delegating wrapper. See [`Self::read_file`].
222    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(&current_content, &effective_old_str, &effective_new_str)
290        else {
291            return Err(edit_not_found_error(
292                &current_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    /// Inline-delegating wrapper. See [`Self::read_file`].
314    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        // Dispatch directly to the underlying list implementation so a top-level
333        // `list_files` call can safely run alongside `unified_search` in the same batch.
334        let tool = self.inventory.file_ops_tool().clone();
335        tool.execute(args).await
336    }
337}