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