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