claude_agent/tools/
edit.rs

1//! Edit tool - performs string replacements in files with TOCTOU protection.
2
3use 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    /// The absolute path to the file to modify
16    pub file_path: String,
17    /// The text to replace
18    pub old_string: String,
19    /// The text to replace it with (must be different from old_string)
20    pub new_string: String,
21    /// Replace all occurences of old_string (default false)
22    #[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}