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.exists() {
70 return Err(ToolError::PathNotFound(file_path.to_string()));
71 }
72
73 let content = tokio::fs::read_to_string(path).await?;
74
75 let count = content.matches(old_string).count();
77
78 if count == 0 {
79 return Err(ToolError::ExecutionFailed(format!(
80 "old_string not found in {file_path}"
81 )));
82 }
83
84 if count > 1 && !replace_all {
85 return Err(ToolError::ExecutionFailed(format!(
86 "old_string found {count} times in {file_path} — use replace_all or provide more context to make it unique"
87 )));
88 }
89
90 let new_content = if replace_all {
91 content.replace(old_string, new_string)
92 } else {
93 content.replacen(old_string, new_string, 1)
94 };
95
96 tokio::fs::write(path, &new_content).await?;
97
98 if replace_all && count > 1 {
99 Ok(format!("Replaced {count} occurrences in {file_path}"))
100 } else {
101 Ok(format!("Edited {file_path}"))
102 }
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use std::io::Write;
110 use tempfile::NamedTempFile;
111
112 fn ctx() -> ToolContext {
113 ToolContext::new(std::env::temp_dir())
114 }
115
116 #[tokio::test]
117 async fn test_edit_file_basic() {
118 let mut f = NamedTempFile::new().unwrap();
119 write!(f, "hello world").unwrap();
120
121 let result = EditFileTool
122 .execute(
123 serde_json::json!({
124 "file_path": f.path().to_str().unwrap(),
125 "old_string": "hello",
126 "new_string": "goodbye"
127 }),
128 &ctx(),
129 )
130 .await
131 .unwrap();
132
133 assert!(result.contains("Edited"));
134 assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "goodbye world");
135 }
136
137 #[tokio::test]
138 async fn test_edit_file_not_found() {
139 let result = EditFileTool
140 .execute(
141 serde_json::json!({
142 "file_path": "/tmp/astrid_nonexistent_12345.txt",
143 "old_string": "a",
144 "new_string": "b"
145 }),
146 &ctx(),
147 )
148 .await;
149
150 assert!(result.is_err());
151 }
152
153 #[tokio::test]
154 async fn test_edit_file_old_string_not_found() {
155 let mut f = NamedTempFile::new().unwrap();
156 write!(f, "hello world").unwrap();
157
158 let result = EditFileTool
159 .execute(
160 serde_json::json!({
161 "file_path": f.path().to_str().unwrap(),
162 "old_string": "foobar",
163 "new_string": "baz"
164 }),
165 &ctx(),
166 )
167 .await;
168
169 assert!(result.is_err());
170 let err = result.unwrap_err();
171 assert!(err.to_string().contains("not found"));
172 }
173
174 #[tokio::test]
175 async fn test_edit_file_non_unique_fails() {
176 let mut f = NamedTempFile::new().unwrap();
177 write!(f, "aaa bbb aaa").unwrap();
178
179 let result = EditFileTool
180 .execute(
181 serde_json::json!({
182 "file_path": f.path().to_str().unwrap(),
183 "old_string": "aaa",
184 "new_string": "ccc"
185 }),
186 &ctx(),
187 )
188 .await;
189
190 assert!(result.is_err());
191 let err = result.unwrap_err();
192 assert!(err.to_string().contains("2 times"));
193 }
194
195 #[tokio::test]
196 async fn test_edit_file_replace_all() {
197 let mut f = NamedTempFile::new().unwrap();
198 write!(f, "aaa bbb aaa").unwrap();
199
200 let result = EditFileTool
201 .execute(
202 serde_json::json!({
203 "file_path": f.path().to_str().unwrap(),
204 "old_string": "aaa",
205 "new_string": "ccc",
206 "replace_all": true
207 }),
208 &ctx(),
209 )
210 .await
211 .unwrap();
212
213 assert!(result.contains("2 occurrences"));
214 assert_eq!(std::fs::read_to_string(f.path()).unwrap(), "ccc bbb ccc");
215 }
216}