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;
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 pub fn read_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
98 self.execute_tool(tools::READ_FILE, args)
99 }
100
101 pub fn write_file(&self, args: Value) -> impl Future<Output = Result<Value>> + '_ {
103 self.execute_tool(tools::WRITE_FILE, args)
104 }
105
106 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, ¤t_content, &effective_old_str, &effective_new_str)?;
174 let new_content = plan.apply(¤t_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 ¤t_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 ¤t_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 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 let tool = self.inventory.file_ops_tool().clone();
252 tool.execute(args).await
253 }
254}