Skip to main content

dot/tools/
multiedit.rs

1use anyhow::{Context, Result};
2use serde_json::Value;
3use std::fs;
4
5use super::Tool;
6
7pub struct MultiEditTool;
8
9impl Tool for MultiEditTool {
10    fn name(&self) -> &str {
11        "multiedit"
12    }
13
14    fn description(&self) -> &str {
15        "Edit multiple sections of a single file in one operation. Each edit specifies an old_text to find and new_text to replace it with. Edits are applied in reverse position order to preserve offsets."
16    }
17
18    fn input_schema(&self) -> Value {
19        serde_json::json!({
20            "type": "object",
21            "properties": {
22                "path": {
23                    "type": "string",
24                    "description": "File path to edit"
25                },
26                "edits": {
27                    "type": "array",
28                    "items": {
29                        "type": "object",
30                        "properties": {
31                            "old_text": {
32                                "type": "string",
33                                "description": "Exact text to find in the file"
34                            },
35                            "new_text": {
36                                "type": "string",
37                                "description": "Replacement text"
38                            }
39                        },
40                        "required": ["old_text", "new_text"]
41                    },
42                    "description": "Array of edits to apply"
43                }
44            },
45            "required": ["path", "edits"]
46        })
47    }
48
49    fn execute(&self, input: Value) -> Result<String> {
50        let path = input["path"]
51            .as_str()
52            .context("Missing required parameter 'path'")?;
53        let edits = input["edits"]
54            .as_array()
55            .context("Missing required parameter 'edits'")?;
56
57        if edits.is_empty() {
58            return Ok("No edits to apply.".to_string());
59        }
60
61        tracing::debug!("multiedit: {} edits on {}", edits.len(), path);
62
63        let mut content =
64            fs::read_to_string(path).with_context(|| format!("Failed to read file: {}", path))?;
65
66        let mut missing: Vec<String> = edits
67            .iter()
68            .filter_map(|e| {
69                let old = e["old_text"].as_str()?;
70                if !content.contains(old) {
71                    Some(old.to_string())
72                } else {
73                    None
74                }
75            })
76            .collect();
77
78        if !missing.is_empty() {
79            missing.dedup();
80            anyhow::bail!(
81                "old_text not found in {}: {}",
82                path,
83                missing
84                    .iter()
85                    .map(|s| format!("{:?}", s))
86                    .collect::<Vec<_>>()
87                    .join(", ")
88            );
89        }
90
91        let mut positioned: Vec<(usize, &str, &str)> = edits
92            .iter()
93            .filter_map(|e| {
94                let old = e["old_text"].as_str()?;
95                let new = e["new_text"].as_str()?;
96                let pos = content.find(old)?;
97                Some((pos, old, new))
98            })
99            .collect();
100
101        positioned.sort_by(|a, b| b.0.cmp(&a.0));
102
103        for (_, old, new) in &positioned {
104            let pos = content
105                .find(old)
106                .with_context(|| format!("old_text {:?} no longer found after prior edits", old))?;
107            content.replace_range(pos..pos + old.len(), new);
108        }
109
110        fs::write(path, &content).with_context(|| format!("Failed to write file: {}", path))?;
111
112        Ok(format!("Applied {} edit(s) to {}", edits.len(), path))
113    }
114}