codetether_agent/tool/
edit.rs1use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{Value, json};
7use similar::{ChangeTag, TextDiff};
8use tokio::fs;
9
10pub struct EditTool;
12
13impl EditTool {
14 pub fn new() -> Self {
15 Self
16 }
17}
18
19#[async_trait]
20impl Tool for EditTool {
21 fn id(&self) -> &str {
22 "edit"
23 }
24
25 fn name(&self) -> &str {
26 "Edit File"
27 }
28
29 fn description(&self) -> &str {
30 "edit(path: string, old_string: string, new_string: string) - Edit a file by replacing an exact string with new content. Include enough context (3+ lines before and after) to uniquely identify the location."
31 }
32
33 fn parameters(&self) -> Value {
34 json!({
35 "type": "object",
36 "properties": {
37 "path": {
38 "type": "string",
39 "description": "The path to the file to edit"
40 },
41 "old_string": {
42 "type": "string",
43 "description": "The exact string to replace (must match exactly, including whitespace)"
44 },
45 "new_string": {
46 "type": "string",
47 "description": "The string to replace old_string with"
48 }
49 },
50 "required": ["path", "old_string", "new_string"],
51 "example": {
52 "path": "src/main.rs",
53 "old_string": "fn old_function() {\n println!(\"old\");\n}",
54 "new_string": "fn new_function() {\n println!(\"new\");\n}"
55 }
56 })
57 }
58
59 async fn execute(&self, args: Value) -> Result<ToolResult> {
60 let path = match args["path"].as_str() {
61 Some(p) => p,
62 None => {
63 return Ok(ToolResult::structured_error(
64 "INVALID_ARGUMENT",
65 "edit",
66 "path is required",
67 Some(vec!["path"]),
68 Some(
69 json!({"path": "src/main.rs", "old_string": "old text", "new_string": "new text"}),
70 ),
71 ));
72 }
73 };
74 let old_string = match args["old_string"].as_str() {
75 Some(s) => s,
76 None => {
77 return Ok(ToolResult::structured_error(
78 "INVALID_ARGUMENT",
79 "edit",
80 "old_string is required",
81 Some(vec!["old_string"]),
82 Some(json!({"path": path, "old_string": "old text", "new_string": "new text"})),
83 ));
84 }
85 };
86 let new_string = match args["new_string"].as_str() {
87 Some(s) => s,
88 None => {
89 return Ok(ToolResult::structured_error(
90 "INVALID_ARGUMENT",
91 "edit",
92 "new_string is required",
93 Some(vec!["new_string"]),
94 Some(json!({"path": path, "old_string": old_string, "new_string": "new text"})),
95 ));
96 }
97 };
98
99 let content = fs::read_to_string(path).await?;
101
102 let count = content.matches(old_string).count();
104
105 if count == 0 {
106 return Ok(ToolResult::structured_error(
107 "NOT_FOUND",
108 "edit",
109 "old_string not found in file. Make sure it matches exactly, including whitespace.",
110 None,
111 Some(json!({
112 "hint": "Use the 'read' tool first to see the exact content of the file",
113 "path": path,
114 "old_string": "<copy exact text from file including whitespace>",
115 "new_string": "replacement text"
116 })),
117 ));
118 }
119
120 if count > 1 {
121 return Ok(ToolResult::structured_error(
122 "AMBIGUOUS_MATCH",
123 "edit",
124 &format!(
125 "old_string found {} times. Include more context to uniquely identify the location.",
126 count
127 ),
128 None,
129 Some(json!({
130 "hint": "Include 3+ lines of context before and after the target text",
131 "matches_found": count
132 })),
133 ));
134 }
135
136 let new_content = content.replacen(old_string, new_string, 1);
138 let diff = TextDiff::from_lines(&content, &new_content);
139
140 let mut diff_output = String::new();
141 let mut added = 0;
142 let mut removed = 0;
143
144 for change in diff.iter_all_changes() {
145 let (sign, style) = match change.tag() {
146 ChangeTag::Delete => {
147 removed += 1;
148 ("-", "red")
149 }
150 ChangeTag::Insert => {
151 added += 1;
152 ("+", "green")
153 }
154 ChangeTag::Equal => (" ", "default"),
155 };
156
157 let line = format!("{}{}", sign, change);
158 if style == "red" {
159 diff_output.push_str(&format!("\x1b[31m{}\x1b[0m", line.trim_end()));
160 } else if style == "green" {
161 diff_output.push_str(&format!("\x1b[32m{}\x1b[0m", line.trim_end()));
162 } else {
163 diff_output.push_str(&line.trim_end());
164 }
165 diff_output.push('\n');
166 }
167
168 let mut metadata = std::collections::HashMap::new();
170 metadata.insert("requires_confirmation".to_string(), serde_json::json!(true));
171 metadata.insert("diff".to_string(), serde_json::json!(diff_output.trim()));
172 metadata.insert("added_lines".to_string(), serde_json::json!(added));
173 metadata.insert("removed_lines".to_string(), serde_json::json!(removed));
174 metadata.insert("path".to_string(), serde_json::json!(path));
175 metadata.insert("old_string".to_string(), serde_json::json!(old_string));
176 metadata.insert("new_string".to_string(), serde_json::json!(new_string));
177
178 Ok(ToolResult {
179 output: format!("Changes require confirmation:\n\n{}", diff_output.trim()),
180 success: true,
181 metadata,
182 })
183 }
184}