agent_code_lib/tools/
multi_edit.rs1use async_trait::async_trait;
14use serde_json::json;
15use similar::TextDiff;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use super::{Tool, ToolContext, ToolResult};
20use crate::error::ToolError;
21
22pub struct MultiEditTool;
23
24async fn check_staleness(path: &Path, ctx: &ToolContext) -> Result<(), String> {
29 let cache = match ctx.file_cache.as_ref() {
30 Some(c) => c,
31 None => return Ok(()),
32 };
33
34 let cached_mtime: SystemTime = {
35 let guard = cache.lock().await;
36 match guard.last_read_mtime(path) {
37 Some(t) => t,
38 None => return Ok(()),
39 }
40 };
41
42 let disk_mtime = tokio::fs::metadata(path)
43 .await
44 .ok()
45 .and_then(|m| m.modified().ok());
46
47 if let Some(disk) = disk_mtime
48 && disk != cached_mtime
49 {
50 return Err(format!(
51 "File changed on disk since last read. \
52 Re-read {} before editing.",
53 path.display()
54 ));
55 }
56
57 Ok(())
58}
59
60fn unified_diff(file_path: &str, before: &str, after: &str) -> String {
62 let diff = TextDiff::from_lines(before, after);
63 let mut out = String::new();
64
65 out.push_str(&format!("--- {file_path}\n"));
66 out.push_str(&format!("+++ {file_path}\n"));
67
68 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
69 out.push_str(&format!("{hunk}"));
70 }
71
72 if out.lines().count() <= 2 {
73 out.push_str("(no visible changes)\n");
74 }
75
76 out
77}
78
79struct EditPair {
81 old_string: String,
82 new_string: String,
83}
84
85fn parse_edits(input: &serde_json::Value) -> Result<Vec<EditPair>, ToolError> {
87 let edits_val = input
88 .get("edits")
89 .ok_or_else(|| ToolError::InvalidInput("'edits' array is required".into()))?;
90
91 let edits_arr = edits_val
92 .as_array()
93 .ok_or_else(|| ToolError::InvalidInput("'edits' must be an array".into()))?;
94
95 if edits_arr.is_empty() {
96 return Err(ToolError::InvalidInput(
97 "'edits' array must contain at least one entry".into(),
98 ));
99 }
100
101 let mut pairs = Vec::with_capacity(edits_arr.len());
102
103 for (idx, entry) in edits_arr.iter().enumerate() {
104 let old = entry
105 .get("old_string")
106 .and_then(|v| v.as_str())
107 .ok_or_else(|| {
108 ToolError::InvalidInput(format!("edits[{idx}]: 'old_string' is required"))
109 })?;
110
111 let new = entry
112 .get("new_string")
113 .and_then(|v| v.as_str())
114 .ok_or_else(|| {
115 ToolError::InvalidInput(format!("edits[{idx}]: 'new_string' is required"))
116 })?;
117
118 if old == new {
119 return Err(ToolError::InvalidInput(format!(
120 "edits[{idx}]: old_string and new_string are identical"
121 )));
122 }
123
124 pairs.push(EditPair {
125 old_string: old.to_owned(),
126 new_string: new.to_owned(),
127 });
128 }
129
130 Ok(pairs)
131}
132
133#[async_trait]
134impl Tool for MultiEditTool {
135 fn name(&self) -> &'static str {
136 "MultiEdit"
137 }
138
139 fn description(&self) -> &'static str {
140 "Apply multiple search-and-replace edits to a single file in one operation. \
141 Each edit must match exactly once. All edits are applied sequentially."
142 }
143
144 fn input_schema(&self) -> serde_json::Value {
145 json!({
146 "type": "object",
147 "required": ["file_path", "edits"],
148 "properties": {
149 "file_path": {
150 "type": "string",
151 "description": "Absolute path to the file to modify"
152 },
153 "edits": {
154 "type": "array",
155 "description": "Ordered list of search-and-replace pairs to apply",
156 "items": {
157 "type": "object",
158 "required": ["old_string", "new_string"],
159 "properties": {
160 "old_string": {
161 "type": "string",
162 "description": "Exact text to find (must match uniquely)"
163 },
164 "new_string": {
165 "type": "string",
166 "description": "Replacement text"
167 }
168 }
169 }
170 }
171 }
172 })
173 }
174
175 fn is_read_only(&self) -> bool {
176 false
177 }
178
179 fn get_path(&self, input: &serde_json::Value) -> Option<PathBuf> {
180 input
181 .get("file_path")
182 .and_then(|v| v.as_str())
183 .map(PathBuf::from)
184 }
185
186 async fn call(
187 &self,
188 input: serde_json::Value,
189 ctx: &ToolContext,
190 ) -> Result<ToolResult, ToolError> {
191 let file_path = input
192 .get("file_path")
193 .and_then(|v| v.as_str())
194 .ok_or_else(|| ToolError::InvalidInput("'file_path' is required".into()))?;
195
196 let edits = parse_edits(&input)?;
197
198 let path = Path::new(file_path);
199
200 const MAX_EDIT_SIZE: u64 = 1_048_576;
202 if let Ok(meta) = tokio::fs::metadata(file_path).await
203 && meta.len() > MAX_EDIT_SIZE
204 {
205 return Err(ToolError::InvalidInput(format!(
206 "File too large for editing ({} bytes, limit {}). \
207 Use Bash with sed/awk for large files.",
208 meta.len(),
209 MAX_EDIT_SIZE
210 )));
211 }
212
213 if let Err(msg) = check_staleness(path, ctx).await {
215 return Err(ToolError::ExecutionFailed(msg));
216 }
217
218 let original = tokio::fs::read_to_string(file_path)
219 .await
220 .map_err(|e| ToolError::ExecutionFailed(format!("Cannot read {file_path}: {e}")))?;
221
222 let mut content = original.clone();
224 let mut applied = 0usize;
225
226 for (idx, pair) in edits.iter().enumerate() {
227 let occurrences = content.matches(&pair.old_string).count();
228
229 if occurrences == 0 {
230 return Err(ToolError::InvalidInput(format!(
231 "edits[{idx}]: old_string not found in {file_path} \
232 (may have been consumed by a prior edit in this batch)"
233 )));
234 }
235
236 if occurrences > 1 {
237 return Err(ToolError::InvalidInput(format!(
238 "edits[{idx}]: old_string matches {occurrences} locations in {file_path}. \
239 Provide a more specific snippet."
240 )));
241 }
242
243 content = content.replacen(&pair.old_string, &pair.new_string, 1);
244 applied += 1;
245 }
246
247 tokio::fs::write(file_path, &content)
249 .await
250 .map_err(|e| ToolError::ExecutionFailed(format!("Cannot write {file_path}: {e}")))?;
251
252 if let Some(cache) = ctx.file_cache.as_ref() {
254 let mut guard = cache.lock().await;
255 guard.invalidate(path);
256 }
257
258 let diff = unified_diff(file_path, &original, &content);
259 Ok(ToolResult::success(format!(
260 "Applied {applied} edit(s) to {file_path}\n\n{diff}"
261 )))
262 }
263}