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