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
8pub struct MultiEditTool;
9
10#[async_trait]
11impl Tool for MultiEditTool {
12 fn definition(&self) -> ToolDefinition {
13 ToolDefinition {
14 name: "multi_edit".to_string(),
15 description:
16 "对单个文件应用多处精确字符串替换,一次性原子写入。\
17 每个编辑必须在前序编辑后的文件状态中精确匹配一次。\
18 若任一编辑失败,文件不会被修改。"
19 .to_string(),
20 parameters: json!({
21 "type": "object",
22 "properties": {
23 "path": {
24 "type": "string",
25 "description": "要编辑的文件路径"
26 },
27 "edits": {
28 "type": "array",
29 "description": "有序的 {old_string, new_string} 替换列表",
30 "items": {
31 "type": "object",
32 "properties": {
33 "old_string": {"type": "string"},
34 "new_string": {"type": "string"}
35 },
36 "required": ["old_string", "new_string"]
37 }
38 }
39 },
40 "required": ["path", "edits"]
41 }),
42 }
43 }
44
45 async fn execute(&self, params: Value) -> Result<String> {
46 let path = params["path"]
47 .as_str()
48 .ok_or_else(|| anyhow::anyhow!("missing 'path'"))?;
49 let edits = params["edits"]
50 .as_array()
51 .ok_or_else(|| anyhow::anyhow!("missing 'edits' array"))?;
52 if edits.is_empty() {
53 anyhow::bail!("'edits' must contain at least one entry");
54 }
55
56 let mut content = tokio::fs::read_to_string(path).await?;
60
61 for (idx, edit) in edits.iter().enumerate() {
62 let old_string = edit["old_string"]
63 .as_str()
64 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'old_string'", idx))?;
65 let new_string = edit["new_string"]
66 .as_str()
67 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'new_string'", idx))?;
68
69 if old_string.is_empty() {
70 anyhow::bail!("edit {}: 'old_string' must not be empty", idx);
72 }
73
74 let count = content.matches(old_string).count();
75 if count == 0 {
76 anyhow::bail!("edit {}: old_string not found", idx);
78 }
79 if count > 1 {
80 anyhow::bail!(
82 "edit {}: old_string found {} times — must be unique",
83 idx,
84 count
85 );
86 }
87
88 content = content.replacen(old_string, new_string, 1);
89 }
90
91 tokio::fs::write(path, &content).await?;
92
93 let mut diff = format!("Applied {} edit(s) to {}\n", edits.len(), path);
95 for (idx, edit) in edits.iter().enumerate() {
96 let old_string = edit["old_string"].as_str().unwrap_or("");
97 let new_string = edit["new_string"].as_str().unwrap_or("");
98 if edits.len() > 1 {
99 diff.push_str(&format!("edit {}:\n", idx + 1));
100 }
101 for line in old_string.lines().take(3) {
102 diff.push_str(&format!("- {}\n", line));
103 }
104 if old_string.lines().count() > 3 {
105 diff.push_str(&format!(" ... ({} more lines removed)\n", old_string.lines().count() - 3));
106 }
107 for line in new_string.lines().take(3) {
108 diff.push_str(&format!("+ {}\n", line));
109 }
110 if new_string.lines().count() > 3 {
111 diff.push_str(&format!(" ... ({} more lines added)\n", new_string.lines().count() - 3));
112 }
113 }
114 Ok(diff)
115 }
116
117 fn risk_level(&self) -> RiskLevel {
118 RiskLevel::Mutating
119 }
120}