1use crate::types::*;
30use async_trait::async_trait;
31
32pub struct EditFileTool;
34
35impl Default for EditFileTool {
36 fn default() -> Self {
37 Self::new()
38 }
39}
40
41impl EditFileTool {
42 pub fn new() -> Self {
43 Self
44 }
45}
46
47#[async_trait]
48impl AgentTool for EditFileTool {
49 fn name(&self) -> &str {
50 "edit_file"
51 }
52
53 fn label(&self) -> &str {
54 "Edit File"
55 }
56
57 fn description(&self) -> &str {
58 "Make a surgical edit to a file by specifying exact text to find and replace. The old_text must match exactly (including whitespace and indentation). For creating new files, use write_file instead."
59 }
60
61 fn parameters_schema(&self) -> serde_json::Value {
62 serde_json::json!({
63 "type": "object",
64 "properties": {
65 "path": {
66 "type": "string",
67 "description": "File path to edit"
68 },
69 "old_text": {
70 "type": "string",
71 "description": "Exact text to find (must match exactly, including whitespace)"
72 },
73 "new_text": {
74 "type": "string",
75 "description": "Text to replace it with"
76 }
77 },
78 "required": ["path", "old_text", "new_text"]
79 })
80 }
81
82 async fn execute(
83 &self,
84 params: serde_json::Value, ctx: ToolContext, ) -> Result<ToolResult, ToolError> {
87 let cancel = ctx.cancel;
88 let path = params["path"]
89 .as_str()
90 .ok_or_else(|| ToolError::InvalidArgs("missing 'path' parameter".into()))?;
91 let old_text = params["old_text"]
92 .as_str()
93 .ok_or_else(|| ToolError::InvalidArgs("missing 'old_text' parameter".into()))?;
94 let new_text = params["new_text"]
95 .as_str()
96 .ok_or_else(|| ToolError::InvalidArgs("missing 'new_text' parameter".into()))?;
97
98 if cancel.is_cancelled() {
99 return Err(ToolError::Cancelled);
100 }
101
102 let content = tokio::fs::read_to_string(path).await.map_err(|e| {
104 ToolError::Failed(format!(
105 "Cannot read {}: {}. Use write_file to create new files.",
106 path, e
107 ))
108 })?;
109
110 let match_count = content.matches(old_text).count();
112
113 if match_count == 0 {
114 let suggestion = find_similar_text(&content, old_text);
116 let hint = if let Some(similar) = suggestion {
117 format!(
118 "\n\nDid you mean:\n```\n{}\n```\nMake sure old_text matches exactly, including whitespace and indentation.",
119 similar
120 )
121 } else {
122 "\n\nTip: Use read_file to see the current file contents, then copy the exact text you want to replace.".into()
123 };
124
125 return Err(ToolError::Failed(format!(
126 "old_text not found in {}.{}",
127 path, hint
128 )));
129 }
130
131 if match_count > 1 {
132 return Err(ToolError::Failed(format!(
133 "old_text matches {} locations in {}. Include more surrounding context to make the match unique.",
134 match_count, path
135 )));
136 }
137
138 let new_content = content.replacen(old_text, new_text, 1);
140
141 tokio::fs::write(path, &new_content)
142 .await
143 .map_err(|e| ToolError::Failed(format!("Cannot write {}: {}", path, e)))?;
144
145 let old_lines = old_text.lines().count();
147 let new_lines = new_text.lines().count();
148 let diff_summary = if old_text == new_text {
149 "No changes (old_text == new_text)".into()
150 } else {
151 format!(
152 "Replaced {} line{} with {} line{} in {}",
153 old_lines,
154 if old_lines == 1 { "" } else { "s" },
155 new_lines,
156 if new_lines == 1 { "" } else { "s" },
157 path
158 )
159 };
160
161 Ok(ToolResult {
162 content: vec![Content::Text { text: diff_summary }],
163 details: serde_json::json!({
164 "path": path,
165 "old_lines": old_lines,
166 "new_lines": new_lines,
167 }),
168 child_loop_id: None,
169 })
170 }
171}
172
173fn find_similar_text(
175 content: &str, target: &str, ) -> Option<String> {
178 let target_trimmed = target.trim();
179 if target_trimmed.is_empty() {
180 return None;
181 }
182
183 let first_line = target_trimmed.lines().next()?;
185 let first_line_trimmed = first_line.trim();
186
187 if first_line_trimmed.is_empty() {
188 return None;
189 }
190
191 let lines: Vec<&str> = content.lines().collect();
193 for (i, line) in lines.iter().enumerate() {
194 if line.contains(first_line_trimmed) {
195 let start = i;
197 let target_line_count = target_trimmed.lines().count();
198 let end = (i + target_line_count + 1).min(lines.len());
199 return Some(lines[start..end].join("\n"));
200 }
201 }
202
203 None
204}