1use anyhow::Result;
6use async_trait::async_trait;
7use serde_json::{Value, json};
8use similar::{ChangeTag, TextDiff};
9use std::collections::HashMap;
10use std::time::Instant;
11use tokio::fs;
12
13use super::{Tool, ToolResult};
14use crate::telemetry::{FileChange, TOOL_EXECUTIONS, ToolExecution, record_persistent};
15
16pub struct ConfirmEditTool;
17
18impl Default for ConfirmEditTool {
19 fn default() -> Self {
20 Self::new()
21 }
22}
23
24impl ConfirmEditTool {
25 pub fn new() -> Self {
26 Self
27 }
28}
29
30#[async_trait]
31impl Tool for ConfirmEditTool {
32 fn id(&self) -> &str {
33 "confirm_edit"
34 }
35
36 fn name(&self) -> &str {
37 "Confirm Edit"
38 }
39
40 fn description(&self) -> &str {
41 "Edit files with confirmation. Shows diff and requires user confirmation before applying changes."
42 }
43
44 fn parameters(&self) -> Value {
45 json!({
46 "type": "object",
47 "properties": {
48 "path": {
49 "type": "string",
50 "description": "The path to the file to edit"
51 },
52 "old_string": {
53 "type": "string",
54 "description": "The exact string to replace"
55 },
56 "new_string": {
57 "type": "string",
58 "description": "The string to replace with"
59 },
60 "confirm": {
61 "type": "boolean",
62 "description": "Set to true to confirm and apply changes, false to reject",
63 "default": null
64 }
65 },
66 "required": ["path", "old_string", "new_string"]
67 })
68 }
69
70 async fn execute(&self, input: Value) -> Result<ToolResult> {
71 let path = match input.get("path").and_then(|v| v.as_str()) {
72 Some(s) => s.to_string(),
73 None => {
74 return Ok(ToolResult::structured_error(
75 "MISSING_FIELD",
76 "confirm_edit",
77 "path is required (path to the file to edit)",
78 Some(vec!["path"]),
79 Some(
80 json!({"path": "src/main.rs", "old_string": "old text", "new_string": "new text"}),
81 ),
82 ));
83 }
84 };
85 let old_string = match input.get("old_string").and_then(|v| v.as_str()) {
86 Some(s) => s.to_string(),
87 None => {
88 return Ok(ToolResult::structured_error(
89 "MISSING_FIELD",
90 "confirm_edit",
91 "old_string is required (the exact string to replace)",
92 Some(vec!["old_string"]),
93 Some(
94 json!({"path": path, "old_string": "text to find", "new_string": "replacement"}),
95 ),
96 ));
97 }
98 };
99 let new_string = match input.get("new_string").and_then(|v| v.as_str()) {
100 Some(s) => s.to_string(),
101 None => {
102 return Ok(ToolResult::structured_error(
103 "MISSING_FIELD",
104 "confirm_edit",
105 "new_string is required (the replacement text)",
106 Some(vec!["new_string"]),
107 Some(
108 json!({"path": path, "old_string": old_string, "new_string": "replacement"}),
109 ),
110 ));
111 }
112 };
113 let confirm = input.get("confirm").and_then(|v| v.as_bool());
114
115 let content = fs::read_to_string(&path).await?;
117
118 let count = content.matches(old_string.as_str()).count();
120
121 if count == 0 {
122 return Ok(ToolResult::structured_error(
123 "NOT_FOUND",
124 "confirm_edit",
125 "old_string not found in file. Make sure it matches exactly, including whitespace.",
126 None,
127 Some(json!({
128 "hint": "Use the 'read' tool first to see the exact content",
129 "path": path,
130 "old_string": "<copy exact text from file>",
131 "new_string": new_string
132 })),
133 ));
134 }
135
136 if count > 1 {
137 return Ok(ToolResult::structured_error(
138 "AMBIGUOUS_MATCH",
139 "confirm_edit",
140 &format!(
141 "old_string found {} times. Include more context to uniquely identify the location.",
142 count
143 ),
144 None,
145 Some(json!({
146 "hint": "Include 3+ lines of context before and after the target text",
147 "matches_found": count
148 })),
149 ));
150 }
151
152 let new_content = content.replacen(old_string.as_str(), &new_string, 1);
154 let diff = TextDiff::from_lines(&content, &new_content);
155
156 let mut diff_output = String::new();
157 let mut added = 0;
158 let mut removed = 0;
159
160 for change in diff.iter_all_changes() {
161 let (sign, style) = match change.tag() {
162 ChangeTag::Delete => {
163 removed += 1;
164 ("-", "red")
165 }
166 ChangeTag::Insert => {
167 added += 1;
168 ("+", "green")
169 }
170 ChangeTag::Equal => (" ", "default"),
171 };
172
173 let line = format!("{}{}", sign, change);
174 if style == "red" {
175 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
176 } else if style == "green" {
177 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
178 } else {
179 diff_output.push_str(line.trim_end());
180 }
181 diff_output.push('\n');
182 }
183
184 if confirm.is_none() {
186 let mut metadata = HashMap::new();
187 metadata.insert("requires_confirmation".to_string(), json!(true));
188 metadata.insert("diff".to_string(), json!(diff_output.trim()));
189 metadata.insert("added_lines".to_string(), json!(added));
190 metadata.insert("removed_lines".to_string(), json!(removed));
191 metadata.insert("path".to_string(), json!(path));
192 metadata.insert("old_string".to_string(), json!(old_string));
193 metadata.insert("new_string".to_string(), json!(new_string));
194
195 return Ok(ToolResult {
196 output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
197 success: true,
198 metadata,
199 });
200 }
201
202 if confirm == Some(true) {
204 let start = Instant::now();
205
206 let lines_before = old_string.lines().count() as u32;
208 let start_line = content[..content.find(old_string.as_str()).unwrap_or(0)]
209 .lines()
210 .count() as u32
211 + 1;
212 let end_line = start_line + lines_before.saturating_sub(1);
213
214 fs::write(&path, &new_content).await?;
216
217 let duration = start.elapsed();
218
219 let file_change = FileChange::modify_with_diff(
221 path.as_str(),
222 diff_output.as_str(),
223 new_string.len(),
224 Some((start_line, end_line)),
225 );
226
227 let mut exec = ToolExecution::start(
228 "confirm_edit",
229 json!({
230 "path": path.as_str(),
231 "old_string": old_string.as_str(),
232 "new_string": new_string.as_str(),
233 }),
234 );
235 exec.add_file_change(file_change);
236 let exec = exec.complete_success(
237 format!(
238 "Applied {} changes (+{} -{}) to {}",
239 added + removed,
240 added,
241 removed,
242 path
243 ),
244 duration,
245 );
246 TOOL_EXECUTIONS.record(exec.success);
247 let _ = record_persistent(
248 "tool_execution",
249 &serde_json::to_value(&exec).unwrap_or_default(),
250 );
251
252 Ok(ToolResult::success(format!(
253 "✓ Changes applied to {}\n\nDiff:\n{}",
254 path,
255 diff_output.trim()
256 )))
257 } else {
258 Ok(ToolResult::success("✗ Changes rejected by user"))
259 }
260 }
261}