Skip to main content

codetether_agent/tool/
confirm_edit.rs

1//! Confirm Edit Tool
2//!
3//! Edit files with user confirmation via diff display
4
5use 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        // Read the file
116        let content = fs::read_to_string(&path).await?;
117
118        // Count occurrences
119        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        // Generate preview diff
153        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 no confirmation provided, return diff for review
185        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        // Handle confirmation
203        if confirm == Some(true) {
204            let start = Instant::now();
205
206            // Calculate line range affected
207            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            // Write the file
215            fs::write(&path, &new_content).await?;
216
217            let duration = start.elapsed();
218
219            // Record telemetry
220            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}