1use async_trait::async_trait;
7use serde::Deserialize;
8use serde_json::Value;
9
10use super::{resolve_path, Tool, ToolContext, ToolResult};
11use crate::error::Result;
12
13const MAX_SIMILAR_MATCHES: usize = 3;
15
16#[derive(Debug, Deserialize)]
17struct EditArgs {
18 file_path: String,
19 old_string: String,
20 new_string: String,
21}
22
23pub struct EditTool;
24
25impl EditTool {
26 pub fn new() -> Self {
27 Self
28 }
29}
30
31impl Default for EditTool {
32 fn default() -> Self {
33 Self::new()
34 }
35}
36
37#[async_trait]
38impl Tool for EditTool {
39 fn name(&self) -> &str {
40 "edit"
41 }
42
43 fn description(&self) -> &str {
44 "Precisely replace text in a file. old_string must have a unique match in the file. Returns similar matches on failure to help with correction."
45 }
46
47 fn parameters_schema(&self) -> Value {
48 serde_json::json!({
49 "type": "object",
50 "properties": {
51 "file_path": {
52 "type": "string",
53 "description": "Target file path (relative or absolute)"
54 },
55 "old_string": {
56 "type": "string",
57 "description": "The original text to replace (must exist uniquely in the file)"
58 },
59 "new_string": {
60 "type": "string",
61 "description": "The replacement text"
62 }
63 },
64 "required": ["file_path", "old_string", "new_string"]
65 })
66 }
67
68 fn requires_confirmation(&self) -> bool {
69 true
70 }
71
72 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
73 let parsed: EditArgs = match serde_json::from_value(args) {
74 Ok(a) => a,
75 Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
76 };
77
78 if parsed.old_string.is_empty() {
80 return Ok(ToolResult::error("old_string cannot be empty".to_string()));
81 }
82 if parsed.file_path.trim().is_empty() {
83 return Ok(ToolResult::error("File path cannot be empty".to_string()));
84 }
85
86 let path = resolve_path(&parsed.file_path, &ctx.working_dir);
87
88 if !path.exists() {
89 return Ok(ToolResult::error(format!("File not found: {}", path.display())));
90 }
91
92 if path.is_dir() {
93 return Ok(ToolResult::error(format!(
94 "'{}' is a directory, not a file",
95 path.display()
96 )));
97 }
98
99 let content = match tokio::fs::read_to_string(&path).await {
100 Ok(c) => c,
101 Err(e) => {
102 return Ok(ToolResult::error(format!(
103 "Failed to read file '{}': {}",
104 path.display(),
105 e
106 )));
107 }
108 };
109
110 let matches: Vec<usize> = content
112 .match_indices(&parsed.old_string)
113 .map(|(pos, _)| pos)
114 .collect();
115
116 match matches.len() {
117 0 => {
118 let similar = find_similar_matches(&content, &parsed.old_string);
120 let mut msg = format!(
121 "No exact match found for old_string in the file.\n\
122 The following are the {} most similar matches. Please check if the selection is correct:\n\n",
123 similar.len()
124 );
125 for (i, m) in similar.iter().enumerate() {
126 msg.push_str(&format!(
127 "Match {} (line {}, score {}):\n expected: {}\n actual: {}\n",
128 i + 1,
129 m.line_number,
130 m.score,
131 truncate(&parsed.old_string, 120),
132 truncate(&m.actual, 120)
133 ));
134 if i + 1 < similar.len() {
135 msg.push('\n');
136 }
137 }
138 Ok(ToolResult::error(msg))
139 }
140 1 => {
141 let new_content = content.replacen(&parsed.old_string, &parsed.new_string, 1);
143
144 match tokio::fs::write(&path, &new_content).await {
145 Ok(()) => {
146 let line_num = count_lines_before(&content, matches[0]);
147 Ok(ToolResult::success(format!(
148 "Modified file: {} (line {})",
149 path.display(),
150 line_num
151 )))
152 }
153 Err(e) => Ok(ToolResult::error(format!(
154 "Failed to write file '{}': {}",
155 path.display(),
156 e
157 ))),
158 }
159 }
160 n => {
161 let lines: Vec<&str> = content.lines().collect();
163 let line_positions: Vec<usize> = matches
164 .iter()
165 .map(|&pos| count_lines_before(&content, pos))
166 .collect();
167
168 let mut msg = format!(
169 "old_string appears {} times",
170 n
171 );
172 let lines_str: Vec<String> = line_positions.iter().map(|l| l.to_string()).collect();
173 msg.push_str(&format!(" (lines {})", lines_str.join(", ")));
174 msg.push_str(", cannot determine a unique replacement location.\nPlease provide more context to make old_string unique.\n\n");
175
176 for &line_1based in line_positions.iter().take(5) {
178 let line_idx = line_1based - 1; let start = line_idx.saturating_sub(1);
180 let end = (line_idx + 2).min(lines.len());
181 msg.push_str("---\n");
182 msg.push_str(&format!("Line {}:\n", line_1based));
183 for (j, line_text) in lines.iter().enumerate().skip(start).take(end - start) {
184 if j == line_idx {
185 msg.push_str(&format!("> {}\n", line_text));
186 } else {
187 msg.push_str(&format!(" {}\n", line_text));
188 }
189 }
190 msg.push_str("---\n");
191 }
192 if n > 5 {
193 msg.push_str(&format!("... and {} more matches not shown\n", n - 5));
194 }
195
196 Ok(ToolResult::error(msg))
197 }
198 }
199 }
200}
201
202struct SimilarMatch {
204 line_number: usize,
205 actual: String,
206 score: usize, }
208
209fn find_similar_matches(content: &str, target: &str) -> Vec<SimilarMatch> {
211 let lines: Vec<&str> = content.lines().collect();
212 let target_lower = target.to_lowercase();
213 let target_words: Vec<&str> = target_lower
215 .split_whitespace()
216 .filter(|w| w.len() >= 2)
217 .collect();
218
219 let mut scored: Vec<(usize, usize, String)> = lines
220 .iter()
221 .enumerate()
222 .map(|(idx, &line)| {
223 let line_lower = line.to_lowercase();
224 let score = word_overlap_score(&target_words, &line_lower);
225 (idx + 1, score, line.trim().to_string())
226 })
227 .filter(|(_, score, _)| *score > 0)
228 .collect();
229
230 scored.sort_by_key(|b| std::cmp::Reverse(b.1));
232
233 scored
235 .into_iter()
236 .take(MAX_SIMILAR_MATCHES)
237 .map(|(line_number, score, actual)| SimilarMatch {
238 line_number,
239 actual,
240 score,
241 })
242 .collect()
243}
244
245fn word_overlap_score(target_words: &[&str], line: &str) -> usize {
247 let line_words: Vec<&str> = line.split_whitespace().collect();
248 let mut score = 0;
249 for &tw in target_words {
250 if line_words.contains(&tw) {
251 score += 1;
252 }
253 }
254 score
255}
256
257fn count_lines_before(content: &str, byte_pos: usize) -> usize {
259 content[..byte_pos].matches('\n').count() + 1
260}
261
262fn truncate(s: &str, max_len: usize) -> String {
264 if s.chars().count() <= max_len {
265 s.to_string()
266 } else {
267 let truncated: String = s.chars().take(max_len).collect();
268 format!("{}...", truncated)
269 }
270}