Skip to main content

ai_agent/tools/
edit.rs

1use crate::types::*;
2use std::fs;
3
4pub const FILE_EDIT_TOOL_NAME: &str = "Edit";
5pub const CLAUDE_FOLDER_PERMISSION_PATTERN: &str = "/.claude/**";
6pub const GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN: &str = "~/.claude/**";
7pub const FILE_UNEXPECTEDLY_MODIFIED_ERROR: &str =
8    "File has been unexpectedly modified. Read it again before attempting to write it.";
9
10pub struct FileEditTool;
11
12impl FileEditTool {
13    pub fn new() -> Self {
14        Self
15    }
16
17    pub fn name(&self) -> &str {
18        "FileEdit"
19    }
20
21    pub fn description(&self) -> &str {
22        "Edit files by performing exact string replacements"
23    }
24
25    pub fn input_schema(&self) -> ToolInputSchema {
26        ToolInputSchema {
27            schema_type: "object".to_string(),
28            properties: serde_json::json!({
29                "file_path": {
30                    "type": "string",
31                    "description": "The absolute path to the file to modify"
32                },
33                "old_string": {
34                    "type": "string",
35                    "description": "The exact text to find and replace"
36                },
37                "new_string": {
38                    "type": "string",
39                    "description": "The replacement text"
40                },
41                "replace_all": {
42                    "type": "boolean",
43                    "description": "Replace all occurrences (default false)"
44                }
45            }),
46            required: Some(vec![
47                "file_path".to_string(),
48                "old_string".to_string(),
49                "new_string".to_string(),
50            ]),
51        }
52    }
53
54    pub async fn execute(
55        &self,
56        input: serde_json::Value,
57        context: &ToolContext,
58    ) -> Result<ToolResult, crate::error::AgentError> {
59        let file_path = input["file_path"]
60            .as_str()
61            .ok_or_else(|| crate::error::AgentError::Tool("file_path is required".to_string()))?;
62
63        let old_string = input["old_string"]
64            .as_str()
65            .ok_or_else(|| crate::error::AgentError::Tool("old_string is required".to_string()))?;
66
67        let new_string = input["new_string"]
68            .as_str()
69            .ok_or_else(|| crate::error::AgentError::Tool("new_string is required".to_string()))?;
70
71        let replace_all = input["replace_all"].as_bool().unwrap_or(false);
72
73        if old_string == new_string {
74            return Ok(ToolResult {
75                result_type: "text".to_string(),
76                tool_use_id: "".to_string(),
77                content: "Error: old_string and new_string are identical".to_string(),
78                is_error: Some(true),
79            });
80        }
81
82        // Resolve relative paths using cwd from context
83        let file_path = if std::path::Path::new(file_path).is_relative() {
84            std::path::Path::new(&context.cwd).join(file_path)
85        } else {
86            std::path::PathBuf::from(file_path)
87        };
88        let file_path_buf = file_path.clone();
89
90        let content =
91            fs::read_to_string(&file_path).map_err(|e| crate::error::AgentError::Io(e))?;
92
93        if !content.contains(old_string) {
94            return Ok(ToolResult {
95                result_type: "text".to_string(),
96                tool_use_id: "".to_string(),
97                content: format!("Error: old_string not found in {}. Make sure it matches exactly including whitespace.", file_path.display()),
98                is_error: Some(true),
99            });
100        }
101
102        let new_content = if replace_all {
103            content.replace(old_string, new_string)
104        } else {
105            // Check uniqueness
106            let count = content.matches(old_string).count();
107            if count > 1 {
108                return Ok(ToolResult {
109                    result_type: "text".to_string(),
110                    tool_use_id: "".to_string(),
111                    content: format!("Error: old_string appears {} times in the file. Provide more context to make it unique, or set replace_all: true.", count),
112                    is_error: Some(true),
113                });
114            }
115            content.replacen(old_string, new_string, 1)
116        };
117
118        fs::write(&file_path_buf, &new_content).map_err(|e| crate::error::AgentError::Io(e))?;
119
120        Ok(ToolResult {
121            result_type: "text".to_string(),
122            tool_use_id: "".to_string(),
123            content: format!("File edited: {}", file_path.display()),
124            is_error: None,
125        })
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_file_edit_tool_name() {
135        let tool = FileEditTool::new();
136        assert_eq!(tool.name(), "FileEdit");
137    }
138
139    #[test]
140    fn test_file_edit_tool_description_contains_edit() {
141        let tool = FileEditTool::new();
142        assert!(tool.description().to_lowercase().contains("edit"));
143    }
144
145    #[test]
146    fn test_file_edit_tool_has_file_path_in_schema() {
147        let tool = FileEditTool::new();
148        let schema = tool.input_schema();
149        assert!(schema.properties.get("file_path").is_some());
150    }
151
152    #[test]
153    fn test_file_edit_tool_has_old_string_in_schema() {
154        let tool = FileEditTool::new();
155        let schema = tool.input_schema();
156        assert!(schema.properties.get("old_string").is_some());
157    }
158
159    #[test]
160    fn test_file_edit_tool_has_new_string_in_schema() {
161        let tool = FileEditTool::new();
162        let schema = tool.input_schema();
163        assert!(schema.properties.get("new_string").is_some());
164    }
165
166    #[test]
167    fn test_file_edit_tool_has_replace_all_in_schema() {
168        let tool = FileEditTool::new();
169        let schema = tool.input_schema();
170        assert!(schema.properties.get("replace_all").is_some());
171    }
172
173    #[tokio::test]
174    async fn test_file_edit_tool_replaces_string() {
175        let temp_dir = std::env::temp_dir();
176        let temp_file = temp_dir.join("test_edit_file.txt");
177        std::fs::write(&temp_file, "Hello, World!").unwrap();
178
179        let tool = FileEditTool::new();
180        let input = serde_json::json!({
181            "file_path": temp_file.to_str().unwrap(),
182            "old_string": "World",
183            "new_string": "Rust"
184        });
185        let context = ToolContext::default();
186
187        let result = tool.execute(input, &context).await;
188        assert!(result.is_ok());
189
190        let read_content = std::fs::read_to_string(&temp_file).unwrap();
191        assert_eq!(read_content, "Hello, Rust!");
192
193        std::fs::remove_file(temp_file).ok();
194    }
195
196    #[tokio::test]
197    async fn test_file_edit_tool_returns_error_for_identical_strings() {
198        let temp_dir = std::env::temp_dir();
199        let temp_file = temp_dir.join("test_edit_identical.txt");
200        std::fs::write(&temp_file, "Hello, World!").unwrap();
201
202        let tool = FileEditTool::new();
203        let input = serde_json::json!({
204            "file_path": temp_file.to_str().unwrap(),
205            "old_string": "Hello",
206            "new_string": "Hello"
207        });
208        let context = ToolContext::default();
209
210        let result = tool.execute(input, &context).await;
211        assert!(result.is_ok());
212        let tool_result = result.unwrap();
213        assert!(tool_result.is_error.is_some() && tool_result.is_error.unwrap());
214
215        std::fs::remove_file(temp_file).ok();
216    }
217
218    #[tokio::test]
219    async fn test_file_edit_tool_returns_error_for_non_existent_string() {
220        let temp_dir = std::env::temp_dir();
221        let temp_file = temp_dir.join("test_edit_not_found.txt");
222        std::fs::write(&temp_file, "Hello, World!").unwrap();
223
224        let tool = FileEditTool::new();
225        let input = serde_json::json!({
226            "file_path": temp_file.to_str().unwrap(),
227            "old_string": "NonExistent",
228            "new_string": "Something"
229        });
230        let context = ToolContext::default();
231
232        let result = tool.execute(input, &context).await;
233        assert!(result.is_ok());
234        let tool_result = result.unwrap();
235        assert!(tool_result.is_error.is_some() && tool_result.is_error.unwrap());
236
237        std::fs::remove_file(temp_file).ok();
238    }
239
240    #[tokio::test]
241    async fn test_file_edit_tool_returns_error_for_ambiguous_replacement() {
242        let temp_dir = std::env::temp_dir();
243        let temp_file = temp_dir.join("test_edit_ambiguous.txt");
244        std::fs::write(&temp_file, "Hello World World").unwrap();
245
246        let tool = FileEditTool::new();
247        let input = serde_json::json!({
248            "file_path": temp_file.to_str().unwrap(),
249            "old_string": "World",
250            "new_string": "Rust"
251        });
252        let context = ToolContext::default();
253
254        let result = tool.execute(input, &context).await;
255        assert!(result.is_ok());
256        let tool_result = result.unwrap();
257        assert!(tool_result.is_error.is_some() && tool_result.is_error.unwrap());
258
259        std::fs::remove_file(temp_file).ok();
260    }
261
262    #[tokio::test]
263    async fn test_file_edit_tool_replace_all() {
264        let temp_dir = std::env::temp_dir();
265        let temp_file = temp_dir.join("test_edit_all.txt");
266        std::fs::write(&temp_file, "Hello World World").unwrap();
267
268        let tool = FileEditTool::new();
269        let input = serde_json::json!({
270            "file_path": temp_file.to_str().unwrap(),
271            "old_string": "World",
272            "new_string": "Rust",
273            "replace_all": true
274        });
275        let context = ToolContext::default();
276
277        let result = tool.execute(input, &context).await;
278        assert!(result.is_ok());
279
280        let read_content = std::fs::read_to_string(&temp_file).unwrap();
281        assert_eq!(read_content, "Hello Rust Rust");
282
283        std::fs::remove_file(temp_file).ok();
284    }
285
286    #[tokio::test]
287    async fn test_file_edit_tool_returns_error_for_nonexistent_file() {
288        let tool = FileEditTool::new();
289        let input = serde_json::json!({
290            "file_path": "/nonexistent/file/that/does/not/exist.txt",
291            "old_string": "test",
292            "new_string": "test2"
293        });
294        let context = ToolContext::default();
295
296        let result = tool.execute(input, &context).await;
297        assert!(result.is_err());
298    }
299}