agent_code_lib/tools/
file_edit.rs1use async_trait::async_trait;
15use serde_json::json;
16use similar::TextDiff;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use super::{Tool, ToolContext, ToolResult};
21use crate::error::ToolError;
22
23pub struct FileEditTool;
24
25async fn check_staleness(path: &Path, ctx: &ToolContext) -> Result<(), String> {
33 let cache = match ctx.file_cache.as_ref() {
34 Some(c) => c,
35 None => return Ok(()),
36 };
37
38 let cached_mtime: SystemTime = {
39 let guard = cache.lock().await;
40 match guard.last_read_mtime(path) {
41 Some(t) => t,
42 None => return Ok(()), }
44 };
45
46 let disk_mtime = tokio::fs::metadata(path)
47 .await
48 .ok()
49 .and_then(|m| m.modified().ok());
50
51 if let Some(disk) = disk_mtime
52 && disk != cached_mtime
53 {
54 return Err(format!(
55 "File was modified since last read. \
56 Please re-read {} before editing.",
57 path.display()
58 ));
59 }
60
61 Ok(())
62}
63
64fn unified_diff(file_path: &str, old: &str, new: &str) -> String {
69 let diff = TextDiff::from_lines(old, new);
70 let mut out = String::new();
71
72 out.push_str(&format!("--- {file_path}\n"));
74 out.push_str(&format!("+++ {file_path}\n"));
75
76 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
77 out.push_str(&format!("{hunk}"));
78 }
79
80 if out.lines().count() <= 2 {
83 out.push_str("(no visible diff)\n");
84 }
85
86 out
87}
88
89#[async_trait]
90impl Tool for FileEditTool {
91 fn name(&self) -> &'static str {
92 "FileEdit"
93 }
94
95 fn description(&self) -> &'static str {
96 "Performs exact string replacements in files. The old_string must \
97 match uniquely unless replace_all is true."
98 }
99
100 fn input_schema(&self) -> serde_json::Value {
101 json!({
102 "type": "object",
103 "required": ["file_path", "old_string", "new_string"],
104 "properties": {
105 "file_path": {
106 "type": "string",
107 "description": "Absolute path to the file to modify"
108 },
109 "old_string": {
110 "type": "string",
111 "description": "The text to replace"
112 },
113 "new_string": {
114 "type": "string",
115 "description": "The replacement text (must differ from old_string)"
116 },
117 "replace_all": {
118 "type": "boolean",
119 "description": "Replace all occurrences (default: false)",
120 "default": false
121 }
122 }
123 })
124 }
125
126 fn is_read_only(&self) -> bool {
127 false
128 }
129
130 fn get_path(&self, input: &serde_json::Value) -> Option<PathBuf> {
131 input
132 .get("file_path")
133 .and_then(|v| v.as_str())
134 .map(PathBuf::from)
135 }
136
137 async fn call(
138 &self,
139 input: serde_json::Value,
140 ctx: &ToolContext,
141 ) -> Result<ToolResult, ToolError> {
142 let file_path = input
143 .get("file_path")
144 .and_then(|v| v.as_str())
145 .ok_or_else(|| ToolError::InvalidInput("'file_path' is required".into()))?;
146
147 let old_string = input
148 .get("old_string")
149 .and_then(|v| v.as_str())
150 .ok_or_else(|| ToolError::InvalidInput("'old_string' is required".into()))?;
151
152 let new_string = input
153 .get("new_string")
154 .and_then(|v| v.as_str())
155 .ok_or_else(|| ToolError::InvalidInput("'new_string' is required".into()))?;
156
157 let replace_all = input
158 .get("replace_all")
159 .and_then(|v| v.as_bool())
160 .unwrap_or(false);
161
162 if old_string == new_string {
163 return Err(ToolError::InvalidInput(
164 "old_string and new_string must be different".into(),
165 ));
166 }
167
168 let path = Path::new(file_path);
169
170 const MAX_EDIT_SIZE: u64 = 1_048_576;
172 if let Ok(meta) = tokio::fs::metadata(file_path).await
173 && meta.len() > MAX_EDIT_SIZE
174 {
175 return Err(ToolError::InvalidInput(format!(
176 "File too large for editing ({} bytes, max {}). \
177 Consider using Bash with sed/awk for large files.",
178 meta.len(),
179 MAX_EDIT_SIZE
180 )));
181 }
182
183 if let Err(msg) = check_staleness(path, ctx).await {
186 return Err(ToolError::ExecutionFailed(msg));
187 }
188
189 let content = tokio::fs::read_to_string(file_path)
190 .await
191 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to read {file_path}: {e}")))?;
192
193 let occurrences = content.matches(old_string).count();
194
195 if occurrences == 0 {
196 return Err(ToolError::InvalidInput(format!(
197 "old_string not found in {file_path}"
198 )));
199 }
200
201 if occurrences > 1 && !replace_all {
202 return Err(ToolError::InvalidInput(format!(
203 "old_string has {occurrences} occurrences in {file_path}. \
204 Use replace_all=true to replace all, or provide a more \
205 specific old_string."
206 )));
207 }
208
209 let new_content = if replace_all {
210 content.replace(old_string, new_string)
211 } else {
212 content.replacen(old_string, new_string, 1)
213 };
214
215 tokio::fs::write(file_path, &new_content)
216 .await
217 .map_err(|e| ToolError::ExecutionFailed(format!("Failed to write {file_path}: {e}")))?;
218
219 if let Some(cache) = ctx.file_cache.as_ref() {
221 let mut guard = cache.lock().await;
222 guard.invalidate(path);
223 }
224
225 let replaced = if replace_all { occurrences } else { 1 };
227 let diff = unified_diff(file_path, &content, &new_content);
228 Ok(ToolResult::success(format!(
229 "Replaced {replaced} occurrence(s) in {file_path}\n\n{diff}"
230 )))
231 }
232}