codetether_agent/tool/
multiedit.rs1use anyhow::{Context, Result};
6use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::{json, Value};
9use std::path::PathBuf;
10use tokio::fs;
11
12use super::{Tool, ToolResult};
13
14pub struct MultiEditTool;
15
16impl Default for MultiEditTool {
17 fn default() -> Self {
18 Self::new()
19 }
20}
21
22impl MultiEditTool {
23 pub fn new() -> Self {
24 Self
25 }
26}
27
28#[derive(Debug, Deserialize)]
29struct MultiEditParams {
30 edits: Vec<EditOperation>,
31}
32
33#[derive(Debug, Deserialize)]
34struct EditOperation {
35 file: String,
36 old_string: String,
37 new_string: String,
38}
39
40#[derive(Debug, serde::Serialize)]
41struct EditResult {
42 file: String,
43 success: bool,
44 message: String,
45}
46
47#[async_trait]
48impl Tool for MultiEditTool {
49 fn id(&self) -> &str {
50 "multiedit"
51 }
52
53 fn name(&self) -> &str {
54 "Multi Edit"
55 }
56
57 fn description(&self) -> &str {
58 "Edit multiple files atomically. Each edit replaces an old string with a new string. \
59 All edits are validated before any changes are applied. If any edit fails validation, \
60 no changes are made."
61 }
62
63 fn parameters(&self) -> Value {
64 json!({
65 "type": "object",
66 "properties": {
67 "edits": {
68 "type": "array",
69 "description": "Array of edit operations to apply",
70 "items": {
71 "type": "object",
72 "properties": {
73 "file": {
74 "type": "string",
75 "description": "Path to the file to edit"
76 },
77 "old_string": {
78 "type": "string",
79 "description": "The exact string to find and replace"
80 },
81 "new_string": {
82 "type": "string",
83 "description": "The string to replace it with"
84 }
85 },
86 "required": ["file", "old_string", "new_string"]
87 }
88 }
89 },
90 "required": ["edits"]
91 })
92 }
93
94 async fn execute(&self, params: Value) -> Result<ToolResult> {
95 let params: MultiEditParams = serde_json::from_value(params)
96 .context("Invalid parameters")?;
97
98 if params.edits.is_empty() {
99 return Ok(ToolResult::error("No edits provided"));
100 }
101
102 let mut file_contents: Vec<(PathBuf, String, String, String)> = Vec::new();
104
105 for edit in ¶ms.edits {
106 let path = PathBuf::from(&edit.file);
107
108 if !path.exists() {
109 return Ok(ToolResult::error(format!(
110 "File does not exist: {}",
111 edit.file
112 )));
113 }
114
115 let content = fs::read_to_string(&path)
116 .await
117 .with_context(|| format!("Failed to read file: {}", edit.file))?;
118
119 let matches: Vec<_> = content.match_indices(&edit.old_string).collect();
121
122 if matches.is_empty() {
123 return Ok(ToolResult::error(format!(
124 "String not found in {}: {}",
125 edit.file,
126 if edit.old_string.len() > 50 {
127 format!("{}...", &edit.old_string[..50])
128 } else {
129 edit.old_string.clone()
130 }
131 )));
132 }
133
134 if matches.len() > 1 {
135 return Ok(ToolResult::error(format!(
136 "String found {} times in {} (must be unique). Use more context to disambiguate.",
137 matches.len(),
138 edit.file
139 )));
140 }
141
142 file_contents.push((
143 path,
144 content,
145 edit.old_string.clone(),
146 edit.new_string.clone(),
147 ));
148 }
149
150 let mut results: Vec<EditResult> = Vec::new();
152
153 for (path, content, old_string, new_string) in file_contents {
154 let new_content = content.replacen(&old_string, &new_string, 1);
155
156 fs::write(&path, &new_content)
157 .await
158 .with_context(|| format!("Failed to write file: {}", path.display()))?;
159
160 results.push(EditResult {
161 file: path.display().to_string(),
162 success: true,
163 message: format!(
164 "Replaced {} chars with {} chars",
165 old_string.len(),
166 new_string.len()
167 ),
168 });
169 }
170
171 let output = format!(
172 "Successfully applied {} edits:\n{}",
173 results.len(),
174 results
175 .iter()
176 .map(|r| format!(" ✓ {}: {}", r.file, r.message))
177 .collect::<Vec<_>>()
178 .join("\n")
179 );
180
181 Ok(ToolResult::success(output)
182 .with_metadata("edits", json!(results)))
183 }
184}