Skip to main content

ai_agent/tools/
edit.rs

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/// Result of a file edit operation, returned as JSON in ToolResult.content
13#[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    /// Count of lines added
23    #[serde(default)]
24    pub additions: usize,
25    /// Count of lines removed
26    #[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        // Resolve relative paths using cwd from context
94        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        // Read original content
102        let content =
103            fs::read_to_string(&file_path).map_err(|e| crate::error::AgentError::Io(e))?;
104
105        // Handle empty old_string as a create/insert operation
106        let new_content = if old_string.is_empty() {
107            // Prepend new_string to existing content
108            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                // Check uniqueness
137                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        // Generate structured patch for the result
157        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    /// Returns the user-facing name for this tool based on input.
185    /// Returns "Update" for edits, "Create" for new files.
186    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    /// Returns a short summary for compact views.
201    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    /// Renders the tool result for display.
206    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        // For plan files, show a hint
210        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        // For new files (create), count all lines as additions
216        if result.old_string.is_empty() {
217            return Some(format!("Added {} lines in {}", result.additions, file_path));
218        }
219
220        // For edits, show additions/removals
221        if result.removals == 0 && result.additions == 0 {
222            // No visible changes (maybe whitespace-only)
223            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}