1use crate::tool::{Tool, ToolResultRenderOptions};
2use crate::types::*;
3use crate::utils::diff::{self, StructuredPatchHunk};
4use std::fs;
5
6pub const FILE_EDIT_TOOL_NAME: &str = "Edit";
7pub const AI_FOLDER_PERMISSION_PATTERN: &str = "/.ai/**";
8pub const GLOBAL_AI_FOLDER_PERMISSION_PATTERN: &str = "~/.ai/**";
9pub const FILE_UNEXPECTEDLY_MODIFIED_ERROR: &str =
10 "File has been unexpectedly modified. Read it again before attempting to write it.";
11
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14pub struct FileEditResult {
15 pub file_path: String,
16 pub old_string: String,
17 pub new_string: String,
18 pub original_file: String,
19 pub structured_patch: Vec<StructuredPatchHunk>,
20 #[serde(default)]
21 pub replace_all: bool,
22 #[serde(default)]
24 pub additions: usize,
25 #[serde(default)]
27 pub removals: usize,
28}
29
30pub struct FileEditTool;
31
32impl FileEditTool {
33 pub fn new() -> Self {
34 Self
35 }
36
37 pub fn name(&self) -> &str {
38 "FileEdit"
39 }
40
41 pub fn description(&self) -> &str {
42 "Edit files by performing exact string replacements"
43 }
44
45 pub fn input_schema(&self) -> ToolInputSchema {
46 ToolInputSchema {
47 schema_type: "object".to_string(),
48 properties: serde_json::json!({
49 "file_path": {
50 "type": "string",
51 "description": "The absolute path to the file to modify"
52 },
53 "old_string": {
54 "type": "string",
55 "description": "The exact text to find and replace"
56 },
57 "new_string": {
58 "type": "string",
59 "description": "The replacement text"
60 },
61 "replace_all": {
62 "type": "boolean",
63 "description": "Replace all occurrences (default false)"
64 }
65 }),
66 required: Some(vec![
67 "file_path".to_string(),
68 "old_string".to_string(),
69 "new_string".to_string(),
70 ]),
71 }
72 }
73
74 pub async fn execute(
75 &self,
76 input: serde_json::Value,
77 context: &ToolContext,
78 ) -> Result<ToolResult, crate::error::AgentError> {
79 let file_path = input["file_path"]
80 .as_str()
81 .ok_or_else(|| crate::error::AgentError::Tool("file_path is required".to_string()))?;
82
83 let old_string = input["old_string"]
84 .as_str()
85 .ok_or_else(|| crate::error::AgentError::Tool("old_string is required".to_string()))?;
86
87 let new_string = input["new_string"]
88 .as_str()
89 .ok_or_else(|| crate::error::AgentError::Tool("new_string is required".to_string()))?;
90
91 let replace_all = input["replace_all"].as_bool().unwrap_or(false);
92
93 let file_path = if std::path::Path::new(file_path).is_relative() {
95 std::path::Path::new(&context.cwd).join(file_path)
96 } else {
97 std::path::PathBuf::from(file_path)
98 };
99 let file_path_buf = file_path.clone();
100
101 let content =
103 fs::read_to_string(&file_path).map_err(|e| crate::error::AgentError::Io(e))?;
104
105 let new_content = if old_string.is_empty() {
107 format!("{}\n{}", new_string, content)
109 } else {
110 if old_string == new_string {
111 return Ok(ToolResult {
112 result_type: "text".to_string(),
113 tool_use_id: "".to_string(),
114 content: "Error: old_string and new_string are identical".to_string(),
115 is_error: Some(true),
116 was_persisted: None,
117 });
118 }
119
120 if !content.contains(old_string) {
121 return Ok(ToolResult {
122 result_type: "text".to_string(),
123 tool_use_id: "".to_string(),
124 content: format!(
125 "Error: old_string not found in {}. Make sure it matches exactly including whitespace.",
126 file_path.display()
127 ),
128 is_error: Some(true),
129 was_persisted: None,
130 });
131 }
132
133 if replace_all {
134 content.replace(old_string, new_string)
135 } else {
136 let count = content.matches(old_string).count();
138 if count > 1 {
139 return Ok(ToolResult {
140 result_type: "text".to_string(),
141 tool_use_id: "".to_string(),
142 content: format!(
143 "Error: old_string appears {} times in the file. Provide more context to make it unique, or set replace_all: true.",
144 count
145 ),
146 is_error: Some(true),
147 was_persisted: None,
148 });
149 }
150 content.replacen(old_string, new_string, 1)
151 }
152 };
153
154 fs::write(&file_path_buf, &new_content).map_err(|e| crate::error::AgentError::Io(e))?;
155
156 let patch = diff::generate_patch(&content, &new_content);
158 let (additions, removals) = diff::count_lines_changed(&patch, Some(&new_content));
159
160 let result = FileEditResult {
161 file_path: file_path_buf.to_string_lossy().to_string(),
162 old_string: old_string.to_string(),
163 new_string: new_string.to_string(),
164 original_file: content,
165 structured_patch: patch,
166 replace_all,
167 additions,
168 removals,
169 };
170
171 let content_json = serde_json::to_string(&result).map_err(|e| {
172 crate::error::AgentError::Tool(format!("Failed to serialize result: {}", e))
173 })?;
174
175 Ok(ToolResult {
176 result_type: "text".to_string(),
177 tool_use_id: "".to_string(),
178 content: content_json,
179 is_error: None,
180 was_persisted: None,
181 })
182 }
183
184 pub fn user_facing_name(&self, input: Option<&serde_json::Value>) -> String {
187 match input {
188 Some(inp) => {
189 let old_string = inp["old_string"].as_str().unwrap_or("");
190 if old_string.is_empty() {
191 "Create".to_string()
192 } else {
193 "Update".to_string()
194 }
195 }
196 None => "Edit".to_string(),
197 }
198 }
199
200 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
202 input.and_then(|inp| inp["file_path"].as_str().map(|s| s.to_string()))
203 }
204
205 pub fn render_tool_result_message(&self, content: &serde_json::Value) -> Option<String> {
207 let result: FileEditResult = serde_json::from_value(content.clone()).ok()?;
208
209 let file_path = &result.file_path;
211 if file_path.contains("/.ai/plans/") || file_path.contains("/.ai/plan/") {
212 return Some(format!("Updated plan: {}", file_path));
213 }
214
215 if result.old_string.is_empty() {
217 return Some(format!("Added {} lines in {}", result.additions, file_path));
218 }
219
220 if result.removals == 0 && result.additions == 0 {
222 return Some(format!("No visible changes to {}", file_path));
224 }
225
226 let mut msg = format!(
227 "Updated {} ({} {})",
228 file_path,
229 result.additions,
230 if result.additions == 1 {
231 "line"
232 } else {
233 "lines"
234 }
235 );
236 if result.removals > 0 {
237 msg.push_str(&format!(
238 ", {} {} removed",
239 result.removals,
240 if result.removals == 1 {
241 "line"
242 } else {
243 "lines"
244 }
245 ));
246 }
247 Some(msg)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn test_file_edit_tool_name() {
257 let tool = FileEditTool::new();
258 assert_eq!(tool.name(), "FileEdit");
259 }
260
261 #[test]
262 fn test_user_facing_name_edit() {
263 let tool = FileEditTool::new();
264 let input = serde_json::json!({
265 "file_path": "/test.txt",
266 "old_string": "old",
267 "new_string": "new"
268 });
269 assert_eq!(tool.user_facing_name(Some(&input)), "Update");
270 }
271
272 #[test]
273 fn test_user_facing_name_create() {
274 let tool = FileEditTool::new();
275 let input = serde_json::json!({
276 "file_path": "/test.txt",
277 "old_string": "",
278 "new_string": "new content"
279 });
280 assert_eq!(tool.user_facing_name(Some(&input)), "Create");
281 }
282
283 #[test]
284 fn test_user_facing_name_no_input() {
285 let tool = FileEditTool::new();
286 assert_eq!(tool.user_facing_name(None), "Edit");
287 }
288
289 #[test]
290 fn test_get_tool_use_summary() {
291 let tool = FileEditTool::new();
292 let input = serde_json::json!({
293 "file_path": "/path/to/file.rs",
294 "old_string": "test",
295 "new_string": "value"
296 });
297 assert_eq!(
298 tool.get_tool_use_summary(Some(&input)),
299 Some("/path/to/file.rs".to_string())
300 );
301 }
302
303 #[test]
304 fn test_get_tool_use_summary_no_path() {
305 let tool = FileEditTool::new();
306 let input = serde_json::json!({
307 "old_string": "test",
308 "new_string": "value"
309 });
310 assert_eq!(tool.get_tool_use_summary(Some(&input)), None);
311 }
312
313 #[test]
314 fn test_render_tool_result_message_edit() {
315 let tool = FileEditTool::new();
316 let result = FileEditResult {
317 file_path: "/test.txt".to_string(),
318 old_string: "old".to_string(),
319 new_string: "new".to_string(),
320 original_file: "old\nline2".to_string(),
321 structured_patch: vec![],
322 replace_all: false,
323 additions: 1,
324 removals: 1,
325 };
326 let rendered = tool.render_tool_result_message(&serde_json::json!(result));
327 assert!(rendered.is_some());
328 let msg = rendered.unwrap();
329 assert!(msg.contains("Updated"));
330 assert!(msg.contains("1 line"));
331 }
332
333 #[test]
334 fn test_render_tool_result_message_create() {
335 let tool = FileEditTool::new();
336 let result = FileEditResult {
337 file_path: "/new.txt".to_string(),
338 old_string: "".to_string(),
339 new_string: "new content".to_string(),
340 original_file: "".to_string(),
341 structured_patch: vec![],
342 replace_all: false,
343 additions: 3,
344 removals: 0,
345 };
346 let rendered = tool.render_tool_result_message(&serde_json::json!(result));
347 assert!(rendered.is_some());
348 assert!(rendered.unwrap().contains("Added 3 lines"));
349 }
350}