astrid_tools/
edit_file.rs1use crate::{BuiltinTool, ToolContext, ToolError, ToolResult};
4use serde_json::Value;
5
6pub struct EditFileTool;
8
9#[async_trait::async_trait]
10impl BuiltinTool for EditFileTool {
11 fn name(&self) -> &'static str {
12 "edit_file"
13 }
14
15 fn description(&self) -> &'static str {
16 "Performs exact string replacements in files. The old_string must be unique in the file \
17 unless replace_all is true. Fails if old_string is not found or matches multiple times \
18 (without replace_all)."
19 }
20
21 fn input_schema(&self) -> Value {
22 serde_json::json!({
23 "type": "object",
24 "properties": {
25 "file_path": {
26 "type": "string",
27 "description": "Absolute path to the file to edit"
28 },
29 "old_string": {
30 "type": "string",
31 "description": "The exact text to find and replace"
32 },
33 "new_string": {
34 "type": "string",
35 "description": "The replacement text"
36 },
37 "replace_all": {
38 "type": "boolean",
39 "description": "Replace all occurrences (default: false)",
40 "default": false
41 }
42 },
43 "required": ["file_path", "old_string", "new_string"]
44 })
45 }
46
47 async fn execute(&self, args: Value, _ctx: &ToolContext) -> ToolResult {
48 let file_path = args
49 .get("file_path")
50 .and_then(Value::as_str)
51 .ok_or_else(|| ToolError::InvalidArguments("file_path is required".into()))?;
52
53 let old_string = args
54 .get("old_string")
55 .and_then(Value::as_str)
56 .ok_or_else(|| ToolError::InvalidArguments("old_string is required".into()))?;
57
58 let new_string = args
59 .get("new_string")
60 .and_then(Value::as_str)
61 .ok_or_else(|| ToolError::InvalidArguments("new_string is required".into()))?;
62
63 let replace_all = args
64 .get("replace_all")
65 .and_then(Value::as_bool)
66 .unwrap_or(false);
67
68 let path = std::path::Path::new(file_path);
69 if !path.is_absolute() {
70 return Err(ToolError::InvalidArguments(
71 "file_path must be an absolute path".into(),
72 ));
73 }
74 if !path.exists() {
75 return Err(ToolError::PathNotFound(file_path.to_string()));
76 }
77
78 let content = tokio::fs::read_to_string(path).await?;
79
80 let count = content.matches(old_string).count();
82
83 if count == 0 {
84 return Err(ToolError::ExecutionFailed(format!(
85 "old_string not found in {file_path}"
86 )));
87 }
88
89 if count > 1 && !replace_all {
90 return Err(ToolError::ExecutionFailed(format!(
91 "old_string found {count} times in {file_path} — use replace_all or provide more context to make it unique"
92 )));
93 }
94
95 let new_content = if replace_all {
96 content.replace(old_string, new_string)
97 } else {
98 content.replacen(old_string, new_string, 1)
99 };
100
101 tokio::fs::write(path, &new_content).await?;
102
103 if replace_all && count > 1 {
104 Ok(format!("Replaced {count} occurrences in {file_path}"))
105 } else {
106 Ok(format!("Edited {file_path}"))
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::io::Write;
115 use tempfile::NamedTempFile;
116
117 fn ctx() -> ToolContext {
118 ToolContext::new(std::env::temp_dir(), None)
119 }
120
121 #[tokio::test]
122 async fn test_edit_file_basic() {
123 let mut f = NamedTempFile::new().unwrap();
124 write!(f, "hello world").unwrap();
125
126 let result = EditFileTool
127 .execute(
128 serde_json::json!({
129 "file_path": f.path().to_str().unwrap(),
130 "old_string": "hello",
131 "new_string": "goodbye"
132 }),
133 &ctx(),
134 )
135 .await
136 .unwrap();
137
138 assert!(result.contains("Edited"));
139 assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "goodbye world");
140 }
141
142 #[tokio::test]
143 async fn test_edit_file_not_found() {
144 let result = EditFileTool
145 .execute(
146 serde_json::json!({
147 "file_path": "/tmp/astrid_nonexistent_12345.txt",
148 "old_string": "a",
149 "new_string": "b"
150 }),
151 &ctx(),
152 )
153 .await;
154
155 assert!(result.is_err());
156 }
157
158 #[tokio::test]
159 async fn test_edit_file_old_string_not_found() {
160 let mut f = NamedTempFile::new().unwrap();
161 write!(f, "hello world").unwrap();
162
163 let result = EditFileTool
164 .execute(
165 serde_json::json!({
166 "file_path": f.path().to_str().unwrap(),
167 "old_string": "foobar",
168 "new_string": "baz"
169 }),
170 &ctx(),
171 )
172 .await;
173
174 assert!(result.is_err());
175 let err = result.unwrap_err();
176 assert!(err.to_string().contains("not found"));
177 }
178
179 #[tokio::test]
180 async fn test_edit_file_non_unique_fails() {
181 let mut f = NamedTempFile::new().unwrap();
182 write!(f, "aaa bbb aaa").unwrap();
183
184 let result = EditFileTool
185 .execute(
186 serde_json::json!({
187 "file_path": f.path().to_str().unwrap(),
188 "old_string": "aaa",
189 "new_string": "ccc"
190 }),
191 &ctx(),
192 )
193 .await;
194
195 assert!(result.is_err());
196 let err = result.unwrap_err();
197 assert!(err.to_string().contains("2 times"));
198 }
199
200 #[tokio::test]
201 async fn test_edit_file_replace_all() {
202 let mut f = NamedTempFile::new().unwrap();
203 write!(f, "aaa bbb aaa").unwrap();
204
205 let result = EditFileTool
206 .execute(
207 serde_json::json!({
208 "file_path": f.path().to_str().unwrap(),
209 "old_string": "aaa",
210 "new_string": "ccc",
211 "replace_all": true
212 }),
213 &ctx(),
214 )
215 .await
216 .unwrap();
217
218 assert!(result.contains("2 occurrences"));
219 assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "ccc bbb ccc");
220 }
221}