1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use tracing::info;
5
6use crate::config::WorkingDirIsolation;
7use microclaw_core::llm_types::ToolDefinition;
8
9use super::{schema_object, Tool, ToolResult};
10
11pub struct EditFileTool {
12 working_dir: PathBuf,
13 working_dir_isolation: WorkingDirIsolation,
14 diff_max_lines: usize,
15 governance_guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
16}
17
18impl EditFileTool {
19 pub fn new(working_dir: &str) -> Self {
20 Self::new_with_isolation(working_dir, WorkingDirIsolation::Shared)
21 }
22
23 pub fn new_with_isolation(
24 working_dir: &str,
25 working_dir_isolation: WorkingDirIsolation,
26 ) -> Self {
27 Self {
28 working_dir: PathBuf::from(working_dir),
29 working_dir_isolation,
30 diff_max_lines: microclaw_core::diff::DEFAULT_DIFF_MAX_LINES,
31 governance_guard: Default::default(),
32 }
33 }
34
35 pub fn with_diff_max_lines(mut self, max_lines: usize) -> Self {
36 self.diff_max_lines = max_lines;
37 self
38 }
39
40 pub fn with_governance_guard(
41 mut self,
42 guard: crate::internal::tool_runtime::path_guard::GovernanceGuard,
43 ) -> Self {
44 self.governance_guard = guard;
45 self
46 }
47}
48
49#[async_trait]
50impl Tool for EditFileTool {
51 fn name(&self) -> &str {
52 "edit_file"
53 }
54
55 fn definition(&self) -> ToolDefinition {
56 ToolDefinition {
57 name: "edit_file".into(),
58 description: "Edit a file by replacing a string match with new content. The match must be unique. The tool first tries an exact match; if that fails it falls back to whitespace/indentation-flexible, escape-normalized, smart-quote-aware, and block-anchor matching, and tells you which strategy succeeded. Prefer paths relative to the current chat working directory; do not invent machine-specific absolute paths unless the user or a tool already provided them.".into(),
59 input_schema: schema_object(
60 json!({
61 "path": {
62 "type": "string",
63 "description": "The file path to edit, usually relative to the current chat working directory"
64 },
65 "old_string": {
66 "type": "string",
67 "description": "The exact string to find and replace (must be unique in the file)"
68 },
69 "new_string": {
70 "type": "string",
71 "description": "The string to replace with"
72 }
73 }),
74 &["path", "old_string", "new_string"],
75 ),
76 }
77 }
78
79 async fn execute(&self, input: serde_json::Value) -> ToolResult {
80 let path = match input.get("path").and_then(|v| v.as_str()) {
81 Some(p) => p,
82 None => return ToolResult::error("Missing 'path' parameter".into()),
83 };
84 let working_dir =
85 super::resolve_tool_working_dir(&self.working_dir, self.working_dir_isolation, &input);
86 let resolved_path = super::resolve_tool_path(&working_dir, path);
87 let resolved_path_str = resolved_path.to_string_lossy().to_string();
88
89 if let Err(msg) = crate::internal::tool_runtime::path_guard::check_path(&resolved_path_str)
90 {
91 return ToolResult::error(msg);
92 }
93 if let Err(msg) = self.governance_guard.check_write(&resolved_path) {
94 return ToolResult::error(msg);
95 }
96
97 let old_string = match input.get("old_string").and_then(|v| v.as_str()) {
98 Some(s) => s,
99 None => return ToolResult::error("Missing 'old_string' parameter".into()),
100 };
101 let new_string = match input.get("new_string").and_then(|v| v.as_str()) {
102 Some(s) => s,
103 None => return ToolResult::error("Missing 'new_string' parameter".into()),
104 };
105
106 info!("Editing file: {}", resolved_path.display());
107
108 let content = match tokio::fs::read_to_string(&resolved_path).await {
109 Ok(c) => c,
110 Err(e) => return ToolResult::error(format!("Failed to read file: {e}")),
111 };
112
113 let fm = match super::fuzzy_match::fuzzy_find_and_replace(&content, old_string, new_string)
114 {
115 Ok(fm) => fm,
116 Err(msg) => return ToolResult::error(msg),
117 };
118
119 match tokio::fs::write(&resolved_path, &fm.new_content).await {
120 Ok(()) => {
121 let suffix = if fm.strategy == "exact" {
122 String::new()
123 } else {
124 format!(" (matched via `{}` strategy — re-read the file if you want to confirm the byte-exact form)", fm.strategy)
125 };
126 let diff = microclaw_core::diff::unified_diff(
127 &content,
128 &fm.new_content,
129 self.diff_max_lines,
130 );
131 let mut result = ToolResult::success(match &diff {
132 Some(d) => format!(
133 "Successfully edited {} ({}){}",
134 resolved_path.display(),
135 d.stats(),
136 suffix
137 ),
138 None => format!("Successfully edited {}{}", resolved_path.display(), suffix),
139 });
140 if let Some(d) = diff {
141 result =
142 result.with_metadata(microclaw_core::diff::file_diff_metadata(path, &d));
143 }
144 result
145 }
146 Err(e) => ToolResult::error(format!("Failed to write file: {e}")),
147 }
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use serde_json::json;
155
156 #[test]
157 fn test_edit_file_definition_prefers_relative_paths() {
158 let tool = EditFileTool::new(".");
159 let def = tool.definition();
160 assert!(def
161 .description
162 .contains("Prefer paths relative to the current chat working directory"));
163 assert_eq!(
164 def.input_schema["properties"]["path"]["description"].as_str(),
165 Some("The file path to edit, usually relative to the current chat working directory")
166 );
167 }
168
169 fn setup_file(content: &str) -> (std::path::PathBuf, std::path::PathBuf) {
170 let dir = std::env::temp_dir().join(format!("microclaw_ef_{}", uuid::Uuid::new_v4()));
171 std::fs::create_dir_all(&dir).unwrap();
172 let file = dir.join("edit_me.txt");
173 std::fs::write(&file, content).unwrap();
174 (dir, file)
175 }
176
177 #[tokio::test]
178 async fn test_edit_file_success() {
179 let (dir, file) = setup_file("hello world");
180 let tool = EditFileTool::new(".");
181 let result = tool
182 .execute(json!({
183 "path": file.to_str().unwrap(),
184 "old_string": "world",
185 "new_string": "rust"
186 }))
187 .await;
188 assert!(!result.is_error);
189 assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello rust");
190 let _ = std::fs::remove_dir_all(&dir);
191 }
192
193 #[tokio::test]
194 async fn test_edit_file_not_found() {
195 let tool = EditFileTool::new(".");
196 let result = tool
197 .execute(json!({
198 "path": "/nonexistent/file.txt",
199 "old_string": "a",
200 "new_string": "b"
201 }))
202 .await;
203 assert!(result.is_error);
204 assert!(result.content.contains("Failed to read file"));
205 }
206
207 #[tokio::test]
208 async fn test_edit_file_old_string_not_found() {
209 let (dir, file) = setup_file("hello world");
210 let tool = EditFileTool::new(".");
211 let result = tool
212 .execute(json!({
213 "path": file.to_str().unwrap(),
214 "old_string": "completely different content xyz",
215 "new_string": "abc"
216 }))
217 .await;
218 assert!(result.is_error);
219 assert!(result.content.contains("Could not find a match"));
220 let _ = std::fs::remove_dir_all(&dir);
221 }
222
223 #[tokio::test]
224 async fn test_edit_file_multiple_matches() {
225 let (dir, file) = setup_file("aaa bbb aaa");
226 let tool = EditFileTool::new(".");
227 let result = tool
228 .execute(json!({
229 "path": file.to_str().unwrap(),
230 "old_string": "aaa",
231 "new_string": "ccc"
232 }))
233 .await;
234 assert!(result.is_error);
235 assert!(result.content.contains("2 matches"));
236 let _ = std::fs::remove_dir_all(&dir);
237 }
238
239 #[tokio::test]
240 async fn test_edit_file_fuzzy_indentation() {
241 let (dir, file) = setup_file(" let x = 1;\n let y = 2;\n");
243 let tool = EditFileTool::new(".");
244 let result = tool
245 .execute(json!({
246 "path": file.to_str().unwrap(),
247 "old_string": "let x = 1;\nlet y = 2;",
248 "new_string": "let x = 1;\nlet y = 99;"
249 }))
250 .await;
251 assert!(!result.is_error, "got error: {}", result.content);
252 assert!(
254 result.content.contains("line_trimmed")
255 || result.content.contains("indentation_flexible"),
256 "expected fuzzy strategy hint, got: {}",
257 result.content
258 );
259 let after = std::fs::read_to_string(&file).unwrap();
260 assert!(after.contains("let y = 99"));
261 let _ = std::fs::remove_dir_all(&dir);
262 }
263
264 #[tokio::test]
265 async fn test_edit_file_missing_params() {
266 let tool = EditFileTool::new(".");
267 let result = tool.execute(json!({})).await;
268 assert!(result.is_error);
269 assert!(result.content.contains("Missing 'path'"));
270
271 let result = tool.execute(json!({"path": "/tmp/x"})).await;
272 assert!(result.is_error);
273 assert!(result.content.contains("Missing 'old_string'"));
274 }
275
276 #[tokio::test]
277 async fn test_edit_file_resolves_relative_to_working_dir() {
278 let root = std::env::temp_dir().join(format!("microclaw_ef2_{}", uuid::Uuid::new_v4()));
279 let work = root.join("workspace");
280 let shared = work.join("shared");
281 std::fs::create_dir_all(&shared).unwrap();
282 let file = shared.join("edit_me.txt");
283 std::fs::write(&file, "aaa bbb").unwrap();
284
285 let tool = EditFileTool::new(work.to_str().unwrap());
286 let result = tool
287 .execute(json!({
288 "path": "edit_me.txt",
289 "old_string": "bbb",
290 "new_string": "ccc"
291 }))
292 .await;
293 assert!(!result.is_error);
294 assert_eq!(std::fs::read_to_string(&file).unwrap(), "aaa ccc");
295
296 let _ = std::fs::remove_dir_all(&root);
297 }
298}