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