Skip to main content

agent_code_lib/tools/
multi_edit.rs

1//! MultiEdit tool: batch search-and-replace across multiple locations in one file.
2//!
3//! Accepts an array of `{old_string, new_string}` pairs and applies them
4//! sequentially to a single file. Each pair performs an exact match
5//! replacement (one occurrence). The tool rejects the entire batch if
6//! any individual edit would fail (missing match, ambiguous match, or
7//! identity replacement), ensuring atomicity.
8//!
9//! Shares the same staleness guard as `FileEdit` — if the file changed
10//! since the model last read it, the batch is rejected until a fresh
11//! read is performed.
12
13use 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
24/// Verify that the file has not been modified since the cache last recorded it.
25///
26/// Returns `Ok(())` when the cache has no entry or the mtimes still agree.
27/// Returns a descriptive error when the file is stale.
28async 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
60/// Build a unified diff between two versions of the same file.
61fn 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
79/// Represents a single search-and-replace pair extracted from the input.
80struct EditPair {
81    old_string: String,
82    new_string: String,
83}
84
85/// Parse and validate the `edits` array from the tool input.
86fn 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        // Reject oversized files.
201        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        // Guard against stale content.
214        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        // Apply each edit sequentially, accumulating the content.
223        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        // Write the result.
248        tokio::fs::write(file_path, &content)
249            .await
250            .map_err(|e| ToolError::ExecutionFailed(format!("Cannot write {file_path}: {e}")))?;
251
252        // Invalidate the cache so the next read picks up our changes.
253        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}