Skip to main content

microclaw_engine/tools/
edit_file.rs

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        // File has 4-space indent; LLM provided 0-space indent.
242        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        // Either line_trimmed or indentation_flexible can win; both fix the issue.
253        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}