1use serde_json::{json, Value};
2use crate::{Result, RuntimeError};
3use super::{Tool, ToolContext, expand_path};
4
5pub struct EditTool;
6
7#[async_trait::async_trait]
8impl Tool for EditTool {
9 fn name(&self) -> &str { "edit" }
10
11 fn description(&self) -> &str {
12 "Make a surgical edit to a file by replacing an exact string match. The old_string must appear exactly once in the file. Provide enough surrounding context to make the match unique."
13 }
14
15 fn parameters(&self) -> Value {
16 json!({
17 "type": "object",
18 "properties": {
19 "path": {
20 "type": "string",
21 "description": "Path to the file to edit"
22 },
23 "old_string": {
24 "type": "string",
25 "description": "The exact text to find and replace. Must match exactly once in the file."
26 },
27 "new_string": {
28 "type": "string",
29 "description": "The replacement text"
30 }
31 },
32 "required": ["path", "old_string", "new_string"]
33 })
34 }
35
36 async fn execute(&self, params: Value, _ctx: ToolContext) -> Result<String> {
37 let raw_path = params["path"].as_str()
38 .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
39 let old_string = params["old_string"].as_str()
40 .ok_or_else(|| RuntimeError::Tool("Missing old_string parameter".to_string()))?;
41 let new_string = params["new_string"].as_str()
42 .ok_or_else(|| RuntimeError::Tool("Missing new_string parameter".to_string()))?;
43
44 let path = expand_path(raw_path);
45
46 let content = tokio::fs::read_to_string(&path).await
47 .map_err(|e| RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e)))?;
48
49 let count = content.matches(old_string).count();
50
51 if count == 0 {
52 return Err(RuntimeError::Tool(format!(
53 "old_string not found in '{}'. Make sure it matches exactly, including whitespace and indentation.",
54 path.display()
55 )));
56 }
57
58 if count > 1 {
59 return Err(RuntimeError::Tool(format!(
60 "old_string found {} times in '{}'. It must be unique — include more surrounding context.",
61 count, path.display()
62 )));
63 }
64
65 let new_content = content.replacen(old_string, new_string, 1);
66
67 let original_perms = tokio::fs::metadata(&path).await
69 .map(|m| m.permissions())
70 .ok();
71
72 let tmp_path = path.with_extension("agent-tmp");
73 tokio::fs::write(&tmp_path, &new_content).await
74 .map_err(|e| RuntimeError::Tool(format!("Failed to write file: {}", e)))?;
75
76 if let Some(perms) = original_perms {
78 let _ = tokio::fs::set_permissions(&tmp_path, perms).await;
79 }
80
81 tokio::fs::rename(&tmp_path, &path).await
82 .map_err(|e| {
83 let tmp = tmp_path.clone();
84 tokio::spawn(async move { let _ = tokio::fs::remove_file(tmp).await; });
85 RuntimeError::Tool(format!("Failed to finalize edit: {}", e))
86 })?;
87
88 let old_lines: Vec<&str> = old_string.lines().collect();
89 let new_lines: Vec<&str> = new_string.lines().collect();
90 Ok(format!(
91 "Edited {} — replaced {} line(s) with {} line(s)",
92 path.display(), old_lines.len(), new_lines.len()
93 ))
94 }
95}
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use super::super::test_helpers::create_tool_context;
100 use crate::tools::Tool;
101 use serde_json::json;
102
103 #[test]
104 fn test_edit_tool_schema() {
105 let tool = EditTool;
106 assert_eq!(tool.name(), "edit");
107 assert!(!tool.description().is_empty());
108
109 let params = tool.parameters();
110 assert_eq!(params["type"], "object");
111 assert!(params["properties"].is_object());
112 assert!(params["required"].is_array());
113 }
114
115 #[tokio::test]
116 async fn test_edit_tool_execution() {
117 let temp_dir = std::env::temp_dir();
118 let test_file = temp_dir.join("edit_tool_test.txt");
119
120 let initial_content = "Hello world\nThis is a test\nEnd of file";
122 std::fs::write(&test_file, initial_content).unwrap();
123
124 let tool = EditTool;
125
126 let ctx = create_tool_context();
128 let params = json!({
129 "path": test_file.to_string_lossy(),
130 "old_string": "This is a test",
131 "new_string": "This is modified"
132 });
133
134 let result = tool.execute(params, ctx).await.unwrap();
135 assert!(result.contains("Edited"));
136 assert!(result.contains("replaced 1 line(s) with 1 line(s)"));
137
138 let modified_content = std::fs::read_to_string(&test_file).unwrap();
139 assert!(modified_content.contains("This is modified"));
140 assert!(!modified_content.contains("This is a test"));
141
142 let ctx = create_tool_context();
144 let params = json!({
145 "path": test_file.to_string_lossy(),
146 "old_string": "nonexistent string",
147 "new_string": "replacement"
148 });
149
150 let result = tool.execute(params, ctx).await;
151 assert!(result.is_err());
152 assert!(result.unwrap_err().to_string().contains("old_string not found"));
153
154 std::fs::write(&test_file, "test\ntest\nother").unwrap();
156 let ctx = create_tool_context();
157 let params = json!({
158 "path": test_file.to_string_lossy(),
159 "old_string": "test",
160 "new_string": "replacement"
161 });
162
163 let result = tool.execute(params, ctx).await;
164 assert!(result.is_err());
165 let error_msg = result.unwrap_err().to_string();
166 assert!(error_msg.contains("found 2 times"));
167 assert!(error_msg.contains("must be unique"));
168
169 let _ = std::fs::remove_file(&test_file);
171 }
172
173 #[tokio::test]
174 async fn test_edit_tool_no_match() {
175 let temp_dir = std::env::temp_dir();
176 let test_file = temp_dir.join("test_edit_tool_no_match.txt");
177
178 let content = "some content\nmore content";
180 std::fs::write(&test_file, content).unwrap();
181
182 let tool = EditTool;
183 let ctx = create_tool_context();
184
185 let params = json!({
186 "path": test_file.to_string_lossy(),
187 "old_string": "this string does not exist",
188 "new_string": "replacement"
189 });
190
191 let result = tool.execute(params, ctx).await;
192
193 assert!(result.is_err());
195 let error = result.unwrap_err().to_string();
196 assert!(error.contains("old_string not found"));
197
198 let _ = std::fs::remove_file(&test_file);
200 }
201}