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 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 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}