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 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        // Read the file
110        let content = fs::read_to_string(&path).await?;
111
112        // Count occurrences
113        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        // Generate preview diff
147        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 no confirmation provided, return diff for review
179        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        // Handle confirmation
197        if confirm == Some(true) {
198            let start = Instant::now();
199
200            // Calculate line range affected
201            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            // Write the file
209            fs::write(&path, &new_content).await?;
210
211            let duration = start.elapsed();
212
213            // Record telemetry
214            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}