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::{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 = &params.path;
77        let old_string = &params.old_string;
78        let new_string = &params.new_string;
79
80        // Read the file
81        let content = fs::read_to_string(path).await?;
82
83        // Count occurrences
84        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        // Generate preview diff
118        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 no confirmation provided, return diff for review
150        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        // Handle confirmation
168        if params.confirm == Some(true) {
169            let start = Instant::now();
170
171            // Calculate line range affected
172            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            // Write the file
180            fs::write(path, &new_content).await?;
181
182            let duration = start.elapsed();
183
184            // Record telemetry
185            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}