claude_agent/tools/
edit.rs1use async_trait::async_trait;
4use schemars::JsonSchema;
5use serde::Deserialize;
6
7use super::SchemaTool;
8use super::context::ExecutionContext;
9use crate::security::fs::SecureFileHandle;
10use crate::types::ToolResult;
11
12#[derive(Debug, Deserialize, JsonSchema)]
13#[schemars(deny_unknown_fields)]
14pub struct EditInput {
15 pub file_path: String,
17 pub old_string: String,
19 pub new_string: String,
21 #[serde(default)]
23 pub replace_all: bool,
24}
25
26#[derive(Debug, Clone, Copy, Default)]
27pub struct EditTool;
28
29#[async_trait]
30impl SchemaTool for EditTool {
31 type Input = EditInput;
32
33 const NAME: &'static str = "Edit";
34 const DESCRIPTION: &'static str = r#"Performs exact string replacements in files.
35
36Usage:
37- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
38- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string.
39- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
40- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
41- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`.
42- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."#;
43
44 async fn handle(&self, input: EditInput, context: &ExecutionContext) -> ToolResult {
45 if input.old_string == input.new_string {
46 return ToolResult::error("old_string and new_string must be different");
47 }
48
49 let path = match context.try_resolve_for(Self::NAME, &input.file_path) {
50 Ok(p) => p,
51 Err(e) => return e,
52 };
53
54 let old_string = input.old_string;
55 let new_string = input.new_string;
56 let replace_all = input.replace_all;
57 let display_path = path.as_path().display().to_string();
58
59 let result = tokio::task::spawn_blocking(move || {
60 let handle =
61 SecureFileHandle::open_read(path.clone()).map_err(|e| e.to_string())?;
62 let original_content = handle.read_to_string().map_err(|e| e.to_string())?;
63
64 let count = original_content.matches(&old_string).count();
65 if count == 0 {
66 return Err("old_string not found in file. Make sure it matches exactly including whitespace.".to_string());
67 }
68 if count > 1 && !replace_all {
69 return Err(format!(
70 "old_string found {} times. Use replace_all=true to replace all, \
71 or provide more context to make it unique.",
72 count
73 ));
74 }
75
76 let new_content = if replace_all {
77 original_content.replace(&old_string, &new_string)
78 } else {
79 original_content.replacen(&old_string, &new_string, 1)
80 };
81
82 let recheck_handle =
83 SecureFileHandle::open_read(path.clone()).map_err(|e| e.to_string())?;
84 let current_content = recheck_handle.read_to_string().map_err(|e| e.to_string())?;
85 if current_content != original_content {
86 return Err("File was modified externally; operation aborted".to_string());
87 }
88
89 let write_handle = SecureFileHandle::open_write(path).map_err(|e| e.to_string())?;
90 write_handle
91 .atomic_write(new_content.as_bytes())
92 .map_err(|e| e.to_string())?;
93
94 Ok(count)
95 })
96 .await;
97
98 match result {
99 Ok(Ok(count)) => {
100 let msg = if replace_all {
101 format!("Replaced {} occurrences in {}", count, display_path)
102 } else {
103 format!("Replaced 1 occurrence in {}", display_path)
104 };
105 ToolResult::success(msg)
106 }
107 Ok(Err(e)) => ToolResult::error(e),
108 Err(e) => ToolResult::error(format!("Task failed: {}", e)),
109 }
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::tools::Tool;
117 use tempfile::tempdir;
118 use tokio::fs;
119
120 #[tokio::test]
121 async fn test_edit_single() {
122 let dir = tempdir().unwrap();
123 let root = std::fs::canonicalize(dir.path()).unwrap();
124 let file_path = root.join("test.txt");
125 fs::write(&file_path, "Hello, World!").await.unwrap();
126
127 let test_context = ExecutionContext::from_path(&root).unwrap();
128 let tool = EditTool;
129
130 let result = tool
131 .execute(
132 serde_json::json!({
133 "file_path": file_path.to_str().unwrap(),
134 "old_string": "World",
135 "new_string": "Rust"
136 }),
137 &test_context,
138 )
139 .await;
140
141 assert!(!result.is_error());
142 let content = fs::read_to_string(&file_path).await.unwrap();
143 assert_eq!(content, "Hello, Rust!");
144 }
145
146 #[tokio::test]
147 async fn test_edit_replace_all() {
148 let dir = tempdir().unwrap();
149 let root = std::fs::canonicalize(dir.path()).unwrap();
150 let file_path = root.join("test.txt");
151 fs::write(&file_path, "foo bar foo").await.unwrap();
152
153 let test_context = ExecutionContext::from_path(&root).unwrap();
154 let tool = EditTool;
155
156 let result = tool
157 .execute(
158 serde_json::json!({
159 "file_path": file_path.to_str().unwrap(),
160 "old_string": "foo",
161 "new_string": "baz",
162 "replace_all": true
163 }),
164 &test_context,
165 )
166 .await;
167
168 assert!(!result.is_error());
169 let content = fs::read_to_string(&file_path).await.unwrap();
170 assert_eq!(content, "baz bar baz");
171 }
172
173 #[tokio::test]
174 async fn test_edit_same_string_error() {
175 let dir = tempdir().unwrap();
176 let root = std::fs::canonicalize(dir.path()).unwrap();
177 let file_path = root.join("test.txt");
178 fs::write(&file_path, "content").await.unwrap();
179
180 let test_context = ExecutionContext::from_path(&root).unwrap();
181 let tool = EditTool;
182
183 let result = tool
184 .execute(
185 serde_json::json!({
186 "file_path": file_path.to_str().unwrap(),
187 "old_string": "same",
188 "new_string": "same"
189 }),
190 &test_context,
191 )
192 .await;
193
194 assert!(result.is_error());
195 }
196
197 #[tokio::test]
198 async fn test_edit_not_found_error() {
199 let dir = tempdir().unwrap();
200 let root = std::fs::canonicalize(dir.path()).unwrap();
201 let file_path = root.join("test.txt");
202 fs::write(&file_path, "Hello, World!").await.unwrap();
203
204 let test_context = ExecutionContext::from_path(&root).unwrap();
205 let tool = EditTool;
206
207 let result = tool
208 .execute(
209 serde_json::json!({
210 "file_path": file_path.to_str().unwrap(),
211 "old_string": "notfound",
212 "new_string": "replacement"
213 }),
214 &test_context,
215 )
216 .await;
217
218 assert!(result.is_error());
219 }
220
221 #[tokio::test]
222 async fn test_edit_multiple_without_replace_all_error() {
223 let dir = tempdir().unwrap();
224 let root = std::fs::canonicalize(dir.path()).unwrap();
225 let file_path = root.join("test.txt");
226 fs::write(&file_path, "foo bar foo").await.unwrap();
227
228 let test_context = ExecutionContext::from_path(&root).unwrap();
229 let tool = EditTool;
230
231 let result = tool
232 .execute(
233 serde_json::json!({
234 "file_path": file_path.to_str().unwrap(),
235 "old_string": "foo",
236 "new_string": "baz"
237 }),
238 &test_context,
239 )
240 .await;
241
242 assert!(result.is_error());
243 }
244
245 #[tokio::test]
246 async fn test_edit_path_escape_blocked() {
247 let dir = tempdir().unwrap();
248 let test_context = ExecutionContext::from_path(dir.path()).unwrap();
249 let tool = EditTool;
250
251 let result = tool
252 .execute(
253 serde_json::json!({
254 "file_path": "/etc/passwd",
255 "old_string": "root",
256 "new_string": "evil"
257 }),
258 &test_context,
259 )
260 .await;
261
262 assert!(result.is_error());
263 }
264
265 #[tokio::test]
266 async fn test_edit_concurrent_modification_detected() {
267 let dir = tempdir().unwrap();
268 let root = std::fs::canonicalize(dir.path()).unwrap();
269 let file_path = root.join("concurrent.txt");
270 let original = "Hello World";
271 std::fs::write(&file_path, original).unwrap();
272
273 let test_context = ExecutionContext::from_path(&root).unwrap();
274
275 std::fs::write(&file_path, "Hello Changed World").unwrap();
276
277 let input = EditInput {
278 file_path: file_path.to_str().unwrap().to_string(),
279 old_string: "Hello".to_string(),
280 new_string: "Hi".to_string(),
281 replace_all: false,
282 };
283
284 let path = test_context.resolve(&input.file_path).unwrap();
285 let old_string = input.old_string.clone();
286 let new_string = input.new_string.clone();
287
288 let result = tokio::task::spawn_blocking(move || {
289 let handle = crate::security::fs::SecureFileHandle::open_read(path.clone()).unwrap();
290 let original_content = handle.read_to_string().unwrap();
291
292 std::fs::write(path.as_path(), "Completely different content").unwrap();
293
294 let new_content = original_content.replacen(&old_string, &new_string, 1);
295
296 let recheck_handle =
297 crate::security::fs::SecureFileHandle::open_read(path.clone()).unwrap();
298 let current_content = recheck_handle.read_to_string().unwrap();
299
300 if current_content != original_content {
301 return Err("File was modified externally; operation aborted".to_string());
302 }
303
304 let write_handle = crate::security::fs::SecureFileHandle::open_write(path).unwrap();
305 write_handle.atomic_write(new_content.as_bytes()).unwrap();
306 Ok(())
307 })
308 .await
309 .unwrap();
310
311 assert!(result.is_err());
312 let message = result.unwrap_err();
313 assert!(
314 message.contains("modified externally"),
315 "Expected 'modified externally' error, got: {}",
316 message
317 );
318 }
319}