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;
23mod replacement;
24use replacement::ReplacementPlan;
25
26const EDIT_FILE_MAX_CHARS: usize = 800;
27const EDIT_FILE_MAX_LINES: usize = 40;
28const EDIT_FILE_MAX_BYTES: u64 = 1_048_576;
29
30fn line_prefix_len(line: &str) -> Option<usize> {
31    let bytes = line.as_bytes();
32    if bytes.is_empty() {
33        return None;
34    }
35
36    let mut i = 0;
37    if bytes[i] == b'L' {
38        i += 1;
39    }
40
41    let start_digits = i;
42    while i < bytes.len() && bytes[i].is_ascii_digit() {
43        i += 1;
44    }
45
46    if i == start_digits || i >= bytes.len() || bytes[i] != b':' {
47        return None;
48    }
49
50    i += 1;
51    if i < bytes.len() && bytes[i] == b' ' {
52        i += 1;
53    }
54
55    Some(i)
56}
57
58fn strip_line_prefixes(text: &str) -> (String, bool) {
59    let lines: Vec<&str> = text.lines().collect();
60    if lines.is_empty() {
61        return (text.to_string(), false);
62    }
63
64    let mut has_prefix = false;
65    let mut all_prefixed = true;
66
67    for line in &lines {
68        if line.trim().is_empty() {
69            continue;
70        }
71
72        if line_prefix_len(line).is_some() {
73            has_prefix = true;
74        } else {
75            all_prefixed = false;
76        }
77    }
78
79    if !has_prefix || !all_prefixed {
80        return (text.to_string(), false);
81    }
82
83    let stripped = text
84        .split_inclusive('\n')
85        .map(|line| match line_prefix_len(line) {
86            Some(prefix_len) => &line[prefix_len..],
87            None => line,
88        })
89        .collect::<String>();
90
91    (stripped, true)
92}
93
94impl ToolRegistry {
95    /// Inline-delegating wrapper that returns the inner future directly to
96    /// avoid an extra coroutine state machine (audit section 16).
97    pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
98        self.execute_tool(tools::READ_FILE, args)
99    }
100
101    /// Inline-delegating wrapper. See [`Self::read_file`].
102    pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
103        self.execute_tool(tools::WRITE_FILE, args)
104    }
105
106    /// Inline-delegating wrapper. See [`Self::read_file`].
107    pub fn create_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
108        self.execute_tool(tools::CREATE_FILE, args)
109    }
110
111    pub async fn edit_file(&self, args: Value) -> Result<Value> {
112        let input: EditInput = deserialize_tool_args(&args, "edit_file")?;
113        let override_snapshot = conflict_override_snapshot(&args);
114
115        let (effective_old_str, _) = strip_line_prefixes(&input.old_str);
116        let (effective_new_str, _) = strip_line_prefixes(&input.new_str);
117
118        if effective_old_str.is_empty() {
119            return Err(anyhow!("edit_file requires non-empty original source; use apply_patch for insertions"));
120        }
121        let old_len = effective_old_str.len();
122        let new_len = effective_new_str.len();
123        let old_lines = effective_old_str.lines().count();
124        let new_lines = effective_new_str.lines().count();
125
126        if old_len > EDIT_FILE_MAX_CHARS
127            || new_len > EDIT_FILE_MAX_CHARS
128            || old_lines > EDIT_FILE_MAX_LINES
129            || new_lines > EDIT_FILE_MAX_LINES
130        {
131            let guidance = crate::tools::error_helpers::USE_PATCH_FOR_LARGE_EDITS;
132            return Err(anyhow!(
133                "edit_file is limited to small literal replacements (≤ {EDIT_FILE_MAX_LINES} lines or ≤ {EDIT_FILE_MAX_CHARS} characters). {guidance}",
134            ));
135        }
136
137        let requested_path = PathBuf::from(&input.path);
138        let canonical_path = resolve_workspace_path(self.workspace_root(), &requested_path)
139            .with_context(|| format!("Failed to resolve path: {}", requested_path.display()))?;
140        let _mutation_lease = self.edited_file_monitor_ref().acquire_mutation(&canonical_path).await;
141
142        let metadata = fs::metadata(&canonical_path)
143            .await
144            .with_context(|| format!("Cannot read file metadata: {}", canonical_path.display()))?;
145        if metadata.len() > EDIT_FILE_MAX_BYTES {
146            let actual = metadata.len();
147            let max = EDIT_FILE_MAX_BYTES;
148            let guidance = crate::tools::error_helpers::USE_PATCH_OR_WRITE_FOR_LARGE_FILES;
149            return Err(anyhow!("File too large for edit_file: {actual} bytes (max: {max} bytes). {guidance}",));
150        }
151
152        let intended_content = self
153            .edited_file_monitor_ref()
154            .tracked_read_text(&canonical_path)
155            .await
156            .and_then(|content| {
157                ReplacementPlan::build(&canonical_path, &content, &effective_old_str, &effective_new_str)
158                    .ok()
159                    .map(|plan| plan.apply(&content))
160            });
161
162        if let Some(conflict) = self
163            .edited_file_monitor_ref()
164            .detect_conflict(&canonical_path, intended_content, override_snapshot.clone())
165            .await?
166        {
167            return Ok(conflict.to_tool_output(self.workspace_root()));
168        }
169
170        let current_content = fs::read_to_string(&canonical_path)
171            .await
172            .with_context(|| format!("Cannot read file: {}", canonical_path.display()))?;
173        let plan = ReplacementPlan::build(&canonical_path, &current_content, &effective_old_str, &effective_new_str)?;
174        let new_content = plan.apply(&current_content);
175        let range = &plan.line_range;
176        tracing::debug!(match_kind = ?plan.match_kind, "Planned literal edit");
177        self.edited_file_monitor_ref().source_inspection().require(
178            &canonical_path,
179            &current_content,
180            std::slice::from_ref(range),
181        )?;
182        let added = range
183            .len()
184            .saturating_add(new_content.lines().count())
185            .saturating_sub(current_content.lines().count());
186        let replacement = crate::tools::source_inspection::mutations::InspectedReplacement {
187            start: range.start,
188            removed: range.len(),
189            added,
190        };
191        let after = new_content.clone();
192        let mut write_args = json!({
193            "path": input.path,
194            "content": new_content,
195            "mode": "overwrite"
196        });
197        if let Some(snapshot) = args.get(FILE_CONFLICT_OVERRIDE_ARG) {
198            write_args[FILE_CONFLICT_OVERRIDE_ARG] = snapshot.clone();
199        }
200
201        let mut result = self.file_ops_tool().write_file_internal(write_args, false).await?;
202        if result.get("conflict_detected").and_then(Value::as_bool) != Some(true) {
203            self.edited_file_monitor_ref().source_inspection().record_edit(
204                &canonical_path,
205                &current_content,
206                &after,
207                &[replacement],
208                crate::tools::source_inspection::call_key(tools::EDIT_FILE, &args),
209            );
210            if let Some(fields) = result.as_object_mut() {
211                let modified_paths = if current_content == after {
212                    Vec::new()
213                } else {
214                    vec![canonical_path]
215                };
216                fields.insert("modified_files".into(), json!(modified_paths));
217            }
218        }
219        Ok(result)
220    }
221
222    /// Inline-delegating wrapper. See [`Self::read_file`].
223    pub fn delete_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
224        self.execute_tool(tools::DELETE_FILE, args)
225    }
226
227    pub async fn grep_file(&self, args: Value) -> Result<Value> {
228        let mut payload = args;
229        if let Some(obj) = payload.as_object_mut() {
230            let has_path = obj
231                .get("path")
232                .and_then(|value| value.as_str())
233                .map(str::trim)
234                .is_some_and(|path| !path.is_empty());
235            if !has_path {
236                obj.insert("path".to_string(), json!("."));
237            }
238        }
239
240        let input = serde_json::from_value(payload)?;
241        self.grep_file_manager().perform_search(input).await.map(|result| json!(result))
242    }
243
244    pub fn last_grep_file_result(&self) -> Option<GrepSearchResult> {
245        self.grep_file_manager().last_result()
246    }
247
248    pub async fn list_files(&self, args: Value) -> Result<Value> {
249        // Dispatch directly to the underlying list implementation so a top-level
250        // `list_files` call can safely run alongside `code_search` in the same batch.
251        let tool = self.inventory.file_ops_tool().clone();
252        tool.execute(args).await
253    }
254}