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 "Apply multiple exact-string replacements to one file in a single \
17 atomic write. Each edit must match exactly once against the file \
18 state after prior edits in the same call. If any edit fails the \
19 file is not modified."
20 .to_string(),
21 parameters: json!({
22 "type": "object",
23 "properties": {
24 "path": {
25 "type": "string",
26 "description": "The file path to edit"
27 },
28 "edits": {
29 "type": "array",
30 "description": "Ordered list of {old_string, new_string} replacements",
31 "items": {
32 "type": "object",
33 "properties": {
34 "old_string": {"type": "string"},
35 "new_string": {"type": "string"}
36 },
37 "required": ["old_string", "new_string"]
38 }
39 }
40 },
41 "required": ["path", "edits"]
42 }),
43 }
44 }
45
46 async fn execute(&self, params: Value) -> Result<String> {
47 let path = params["path"]
48 .as_str()
49 .ok_or_else(|| anyhow::anyhow!("missing 'path'"))?;
50 let edits = params["edits"]
51 .as_array()
52 .ok_or_else(|| anyhow::anyhow!("missing 'edits' array"))?;
53 if edits.is_empty() {
54 anyhow::bail!("'edits' must contain at least one entry");
55 }
56
57 let mut content = tokio::fs::read_to_string(path).await?;
61
62 for (idx, edit) in edits.iter().enumerate() {
63 let old_string = edit["old_string"]
64 .as_str()
65 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'old_string'", idx))?;
66 let new_string = edit["new_string"]
67 .as_str()
68 .ok_or_else(|| anyhow::anyhow!("edit {}: missing 'new_string'", idx))?;
69
70 if old_string.is_empty() {
71 anyhow::bail!("edit {}: 'old_string' must not be empty", idx);
73 }
74
75 let count = content.matches(old_string).count();
76 if count == 0 {
77 anyhow::bail!("edit {}: old_string not found", idx);
79 }
80 if count > 1 {
81 anyhow::bail!(
83 "edit {}: old_string found {} times — must be unique",
84 idx,
85 count
86 );
87 }
88
89 content = content.replacen(old_string, new_string, 1);
90 }
91
92 tokio::fs::write(path, &content).await?;
93
94 let mut diff = format!("Applied {} edit(s) to {}\n", edits.len(), path);
96 for (idx, edit) in edits.iter().enumerate() {
97 let old_string = edit["old_string"].as_str().unwrap_or("");
98 let new_string = edit["new_string"].as_str().unwrap_or("");
99 if edits.len() > 1 {
100 diff.push_str(&format!("edit {}:\n", idx + 1));
101 }
102 for line in old_string.lines().take(3) {
103 diff.push_str(&format!("- {}\n", line));
104 }
105 if old_string.lines().count() > 3 {
106 diff.push_str(&format!(" ... ({} more lines removed)\n", old_string.lines().count() - 3));
107 }
108 for line in new_string.lines().take(3) {
109 diff.push_str(&format!("+ {}\n", line));
110 }
111 if new_string.lines().count() > 3 {
112 diff.push_str(&format!(" ... ({} more lines added)\n", new_string.lines().count() - 3));
113 }
114 }
115 Ok(diff)
116 }
117
118 fn risk_level(&self) -> RiskLevel {
119 RiskLevel::Mutating
120 }
121}