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
8/// Normalize line endings for cross-platform compatibility
9/// Converts CRLF (\r\n) to LF (\n) to handle Windows/Linux differences
10fn normalize_line_endings(s: &str) -> String {
11    s.replace("\r\n", "\n").replace("\r", "\n")
12}
13
14/// Detect if original content uses CRLF line endings
15fn 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        // Show spinner while editing - RAII guard ensures cleanup on error
68        // let mut spinner = ToolSpinner::new(&format!("multi-editing {} ({} edits)", path, edits.len()));
69
70        let original_content = tokio::fs::read_to_string(path).await?;
71
72        // Normalize line endings for cross-platform compatibility
73        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                // spinner.finish_error("empty old_string");
86                anyhow::bail!("edit {}: 'old_string' must not be empty", idx);
87            }
88
89            // Normalize line endings for matching
90            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                // spinner.finish_error(&format!("edit {} not found", idx));
96                anyhow::bail!("edit {}: old_string not found", idx);
97            }
98            if count > 1 {
99                // spinner.finish_error(&format!("edit {} multiple matches", idx));
100                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        // Restore original line ending style if needed
111        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        // Return diff-style output
120        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}