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.exists() {
70            return Err(ToolError::PathNotFound(file_path.to_string()));
71        }
72
73        let content = tokio::fs::read_to_string(path).await?;
74
75        // Count occurrences
76        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}