Skip to main content

astrid_tools/
edit_file.rs

1//! Edit file tool — performs exact string replacements in files.
2
3use crate::{BuiltinTool, ToolContext, ToolError, ToolResult};
4use serde_json::Value;
5
6/// Built-in tool for editing files via string replacement.
7pub 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        // Count occurrences
81        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}