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(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            // Pre-compute normalised old lines once (avoid re-allocation per window).
138            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    // The model already has old_str; keep only a disambiguating head/tail.
189    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    /// Inline-delegating wrapper that returns the inner future directly to
204    /// avoid an extra coroutine state machine (audit section 16).
205    pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
206        self.execute_tool(tools::READ_FILE, args)
207    }
208
209    /// Inline-delegating wrapper. See [`Self::read_file`].
210    pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
211        self.execute_tool(tools::WRITE_FILE, args)
212    }
213
214    /// Inline-delegating wrapper. See [`Self::read_file`].
215    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(&current_content, &effective_old_str, &effective_new_str) else {
275            return Err(edit_not_found_error(&current_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    /// Inline-delegating wrapper. See [`Self::read_file`].
291    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        // Dispatch directly to the underlying list implementation so a top-level
318        // `list_files` call can safely run alongside `code_search` in the same batch.
319        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}