Skip to main content

matrixcode_core/tools/
edit.rs

1use anyhow::Result;
2use async_trait::async_trait;
3use serde_json::{Value, json};
4
5use super::{Tool, ToolDefinition};
6use crate::approval::RiskLevel;
7
8/// Maximum file size for safe editing (1MB)
9const MAX_EDIT_FILE_SIZE: u64 = 1_000_000;
10
11/// Normalize line endings for cross-platform compatibility
12/// Converts CRLF (\r\n) to LF (\n) to handle Windows/Linux differences
13fn normalize_line_endings(s: &str) -> String {
14    s.replace("\r\n", "\n").replace("\r", "\n")
15}
16
17/// Detect if original content uses CRLF line endings
18fn uses_crlf(content: &str) -> bool {
19    content.contains("\r\n")
20}
21
22pub struct EditTool;
23
24#[async_trait]
25impl Tool for EditTool {
26    fn definition(&self) -> ToolDefinition {
27        ToolDefinition {
28            name: "edit".to_string(),
29            description: "在文件中查找精确匹配的字符串并替换为新内容。
30
31【重要】编辑前必须先读取:
32- 你必须在对话中至少用 read 工具读一次该文件
33- 如果尝试编辑前没读文件,此工具会报错
34- 确保了解文件当前状态和上下文后再修改
35
36适用场景:
37- 单处代码修改(改一个函数名)
38- 精确替换(必须唯一匹配)
39- 小范围改动(<10行)
40
41不适用场景:
42- ❌ 同一文件多处修改 → 用 multi_edit(批量替换)
43- ❌ 大范围重构 → 先 enter_plan_mode 规划
44- ❌ 创建新文件 → 用 write
45
46优先级:[高] 小改动首选,精确且安全"
47                .to_string(),
48            parameters: json!({
49                "type": "object",
50                "properties": {
51                    "path": {
52                        "type": "string",
53                        "description": "要编辑的文件路径"
54                    },
55                    "old_string": {
56                        "type": "string",
57                        "description": "要查找并替换的原始字符串(必须精确匹配)"
58                    },
59                    "new_string": {
60                        "type": "string",
61                        "description": "替换后的新字符串"
62                    }
63                },
64                "required": ["path", "old_string", "new_string"]
65            }),
66            ..Default::default()
67        }
68    }
69
70    async fn execute(&self, params: Value) -> Result<String> {
71        let path = params["path"]
72            .as_str()
73            .ok_or_else(|| anyhow::anyhow!("missing 'path'"))?;
74        let old_string = params["old_string"]
75            .as_str()
76            .ok_or_else(|| anyhow::anyhow!("missing 'old_string'"))?;
77        let new_string = params["new_string"]
78            .as_str()
79            .ok_or_else(|| anyhow::anyhow!("missing 'new_string'"))?;
80
81        // Check file size first
82        let metadata = tokio::fs::metadata(path).await?;
83        let file_size = metadata.len();
84
85        if file_size > MAX_EDIT_FILE_SIZE {
86            return Ok(format!(
87                "⚠️ File is too large ({:.1}MB) for safe editing.\n\
88                 Large file edits may cause memory issues.\n\
89                 Consider using other methods:\n\
90                 - Use `bash` with sed/awk for large files\n\
91                 - Split the file into smaller sections first",
92                file_size as f64 / 1_000_000.0
93            ));
94        }
95
96        let content = tokio::fs::read_to_string(path).await?;
97
98        // Normalize line endings for cross-platform compatibility
99        // This handles the case where file uses CRLF but old_string uses LF
100        let original_uses_crlf = uses_crlf(&content);
101        let normalized_content = normalize_line_endings(&content);
102        let normalized_old = normalize_line_endings(old_string);
103        let normalized_new = normalize_line_endings(new_string);
104
105        let count = normalized_content.matches(&normalized_old).count();
106        if count == 0 {
107            // Provide helpful error message with context
108            let hint = if old_string.contains('\n') && !content.contains('\r') {
109                "\n提示: 文件使用 CRLF 换行符,请确保 old_string 与文件内容完全匹配"
110            } else {
111                ""
112            };
113            anyhow::bail!("old_string not found in {}{}", path, hint);
114        }
115        if count > 1 {
116            anyhow::bail!(
117                "old_string found {} times in {} — must be unique",
118                count,
119                path
120            );
121        }
122
123        // Perform replacement on normalized content
124        let new_normalized_content = normalized_content.replacen(&normalized_old, &normalized_new, 1);
125
126        // Restore original line ending style if needed
127        let final_content = if original_uses_crlf {
128            new_normalized_content.replace('\n', "\r\n")
129        } else {
130            new_normalized_content
131        };
132
133        tokio::fs::write(path, &final_content).await?;
134
135        // Return diff-style output
136        let old_lines: Vec<&str> = normalized_old.lines().collect();
137        let new_lines: Vec<&str> = normalized_new.lines().collect();
138        let mut diff = format!("Successfully edited {}\n", path);
139        for line in &old_lines {
140            diff.push_str(&format!("- {}\n", line));
141        }
142        for line in &new_lines {
143            diff.push_str(&format!("+ {}\n", line));
144        }
145        Ok(diff)
146    }
147
148    fn risk_level(&self) -> RiskLevel {
149        RiskLevel::Mutating
150    }
151}