Skip to main content

matrixcode_core/tools/
multi_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
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        // Show spinner while editing - RAII guard ensures cleanup on error
58        // let mut spinner = ToolSpinner::new(&format!("multi-editing {} ({} edits)", path, edits.len()));
59
60        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                // spinner.finish_error("empty old_string");
72                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                // spinner.finish_error(&format!("edit {} not found", idx));
78                anyhow::bail!("edit {}: old_string not found", idx);
79            }
80            if count > 1 {
81                // spinner.finish_error(&format!("edit {} multiple matches", idx));
82                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        // spinner.finish_success(&format!("{} edits applied", edits.len()));
94        Ok(format!("Applied {} edit(s) to {}", edits.len(), path))
95    }
96
97    fn risk_level(&self) -> RiskLevel {
98        RiskLevel::Mutating
99    }
100}