matrixcode_core/tools/
multi_edit.rs1use anyhow::Result;
2use async_trait::async_trait;
3use serde_json::{Value, json};
4
5use super::{Tool, ToolDefinition};
6use crate::approval::RiskLevel;
7
8fn normalize_line_endings(s: &str) -> String {
11 s.replace("\r\n", "\n").replace("\r", "\n")
12}
13
14fn uses_crlf(content: &str) -> bool {
16 content.contains("\r\n")
17}
18
19pub struct MultiEditTool;
20
21#[async_trait]
22impl Tool for MultiEditTool {
23 fn definition(&self) -> ToolDefinition {
24 ToolDefinition {
25 name: "multi_edit".to_string(),
26 description: "对单个文件应用多处精确字符串替换,一次性原子写入。\
27 每个编辑必须在前序编辑后的文件状态中精确匹配一次。\
28 若任一编辑失败,文件不会被修改。"
29 .to_string(),
30 parameters: json!({
31 "type": "object",
32 "properties": {
33 "path": {
34 "type": "string",
35 "description": "要编辑的文件路径"
36 },
37 "edits": {
38 "type": "array",
39 "description": "有序的 {old_string, new_string} 替换列表",
40 "items": {
41 "type": "object",
42 "properties": {
43 "old_string": {"type": "string"},
44 "new_string": {"type": "string"}
45 },
46 "required": ["old_string", "new_string"]
47 }
48 }
49 },
50 "required": ["path", "edits"]
51 }),
52 ..Default::default()
53 }
54 }
55
56 async fn execute(&self, params: Value) -> Result<String> {
57 let path = params["path"]
58 .as_str()
59 .ok_or_else(|| anyhow::anyhow!("missing 'path'"))?;
60 let edits = params["edits"]
61 .as_array()
62 .ok_or_else(|| anyhow::anyhow!("missing 'edits' array"))?;
63 if edits.is_empty() {
64 anyhow::bail!("'edits' must contain at least one entry");
65 }
66
67 let original_content = tokio::fs::read_to_string(path).await?;
71
72 let original_uses_crlf = uses_crlf(&original_content);
74 let mut content = normalize_line_endings(&original_content);
75
76 for (idx, edit) in edits.iter().enumerate() {
77 let old_string = edit["old_string"]
78 .as_str()
79 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'old_string'", idx))?;
80 let new_string = edit["new_string"]
81 .as_str()
82 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'new_string'", idx))?;
83
84 if old_string.is_empty() {
85 anyhow::bail!("edit {}: 'old_string' must not be empty", idx);
87 }
88
89 let normalized_old = normalize_line_endings(old_string);
91 let normalized_new = normalize_line_endings(new_string);
92
93 let count = content.matches(&normalized_old).count();
94 if count == 0 {
95 anyhow::bail!("edit {}: old_string not found", idx);
97 }
98 if count > 1 {
99 anyhow::bail!(
101 "edit {}: old_string found {} times — must be unique",
102 idx,
103 count
104 );
105 }
106
107 content = content.replacen(&normalized_old, &normalized_new, 1);
108 }
109
110 let final_content = if original_uses_crlf {
112 content.replace('\n', "\r\n")
113 } else {
114 content
115 };
116
117 tokio::fs::write(path, &final_content).await?;
118
119 let mut diff = format!("Applied {} edit(s) to {}\n", edits.len(), path);
121 for (idx, edit) in edits.iter().enumerate() {
122 let old_string = edit["old_string"].as_str().unwrap_or("");
123 let new_string = edit["new_string"].as_str().unwrap_or("");
124 let normalized_old = normalize_line_endings(old_string);
125 let normalized_new = normalize_line_endings(new_string);
126 if edits.len() > 1 {
127 diff.push_str(&format!("edit {}:\n", idx + 1));
128 }
129 for line in normalized_old.lines().take(3) {
130 diff.push_str(&format!("- {}\n", line));
131 }
132 if normalized_old.lines().count() > 3 {
133 diff.push_str(&format!(
134 " ... ({} more lines removed)\n",
135 normalized_old.lines().count() - 3
136 ));
137 }
138 for line in normalized_new.lines().take(3) {
139 diff.push_str(&format!("+ {}\n", line));
140 }
141 if normalized_new.lines().count() > 3 {
142 diff.push_str(&format!(
143 " ... ({} more lines added)\n",
144 normalized_new.lines().count() - 3
145 ));
146 }
147 }
148 Ok(diff)
149 }
150
151 fn risk_level(&self) -> RiskLevel {
152 RiskLevel::Mutating
153 }
154}