1use std::fs;
7use std::path::Path;
8use thiserror::Error;
9
10#[derive(Error, Debug)]
11pub enum EditError {
12 #[error("File not found: {0}")]
13 FileNotFound(String),
14
15 #[error("Pattern not found: {0}")]
16 PatternNotFound(String),
17
18 #[error("Line {line} does not exist (file has {total_lines} lines)")]
19 InvalidLine { line: usize, total_lines: usize },
20
21 #[error("Ambiguous match: found {count} occurrences of '{pattern}'")]
22 AmbiguousMatch { pattern: String, count: usize },
23
24 #[error("IO error: {0}")]
25 Io(#[from] std::io::Error),
26
27 #[error("File contains invalid UTF-8")]
28 InvalidUtf8,
29}
30
31#[derive(Debug, Clone)]
33pub struct EditResult {
34 pub success: bool,
35 pub message: String, pub file_path: String,
37 pub line_changed: Option<usize>,
38 pub old_content: String,
39 pub new_content: String,
40 pub context_before: Vec<String>, pub context_after: Vec<String>, }
43
44pub struct EditTool {
46 }
48
49impl EditTool {
50 pub const fn new() -> Self {
51 Self {}
52 }
53
54 pub fn edit_line(
56 file_path: &str,
57 line_number: usize,
58 new_content: &str,
59 ) -> Result<EditResult, EditError> {
60 if !Path::new(file_path).exists() {
62 return Err(EditError::FileNotFound(file_path.to_string()));
63 }
64
65 let content = fs::read_to_string(file_path)
67 .map_err(EditError::Io)?
68 .replace('\r', ""); if content.chars().any(|c| c == '\u{FFFD}') {
72 return Err(EditError::InvalidUtf8);
73 }
74
75 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
76
77 if line_number == 0 || line_number > lines.len() {
79 return Err(EditError::InvalidLine {
80 line: line_number,
81 total_lines: lines.len(),
82 });
83 }
84
85 let line_index = line_number - 1;
87 let old_content = lines[line_index].clone();
88
89 let indentation = Self::detect_indentation(&old_content);
91 let new_content_with_indent = if new_content.trim().is_empty() {
92 String::new() } else {
94 format!("{}{}", indentation, new_content.trim())
95 };
96
97 lines[line_index] = new_content_with_indent.clone();
99
100 let backup_path = format!("{}.backup", file_path);
102 fs::write(&backup_path, &content).map_err(EditError::Io)?;
103
104 let new_file_content = lines.join("\n");
106 let temp_path = format!("{}.tmp", file_path);
107 fs::write(&temp_path, &new_file_content).map_err(EditError::Io)?;
108 fs::rename(&temp_path, file_path).map_err(EditError::Io)?;
109
110 let (context_before, context_after) = Self::extract_context(&lines, line_index);
112
113 Ok(EditResult {
114 success: true,
115 message: format!(
116 "Successfully changed line {} from '{}' to '{}'",
117 line_number,
118 old_content.trim(),
119 new_content_with_indent.trim()
120 ),
121 file_path: file_path.to_string(),
122 line_changed: Some(line_number),
123 old_content,
124 new_content: new_content_with_indent,
125 context_before,
126 context_after,
127 })
128 }
129
130 pub fn edit_text(
132 file_path: &str,
133 old_text: &str,
134 new_text: &str,
135 ) -> Result<EditResult, EditError> {
136 if !Path::new(file_path).exists() {
138 return Err(EditError::FileNotFound(file_path.to_string()));
139 }
140
141 let content = fs::read_to_string(file_path)
143 .map_err(EditError::Io)?
144 .replace('\r', ""); if content.chars().any(|c| c == '\u{FFFD}') {
148 return Err(EditError::InvalidUtf8);
149 }
150
151 let matches: Vec<_> = content.match_indices(old_text).collect();
153
154 if matches.is_empty() {
155 return Err(EditError::PatternNotFound(old_text.to_string()));
156 }
157
158 if matches.len() > 1 {
160 return Err(EditError::AmbiguousMatch {
161 pattern: old_text.to_string(),
162 count: matches.len(),
163 });
164 }
165
166 let new_content = content.replace(old_text, new_text);
168
169 let match_pos = matches[0].0;
171 let line_number = content[..match_pos].matches('\n').count() + 1;
172
173 let backup_path = format!("{}.backup", file_path);
175 fs::write(&backup_path, &content).map_err(EditError::Io)?;
176
177 let temp_path = format!("{}.tmp", file_path);
179 fs::write(&temp_path, &new_content).map_err(EditError::Io)?;
180 fs::rename(&temp_path, file_path).map_err(EditError::Io)?;
181
182 let lines: Vec<String> = new_content.lines().map(|s| s.to_string()).collect();
184 let line_index = line_number.saturating_sub(1);
185 let (context_before, context_after) = Self::extract_context(&lines, line_index);
186
187 Ok(EditResult {
188 success: true,
189 message: format!(
190 "Successfully replaced '{}' with '{}' at line {}",
191 old_text.chars().take(50).collect::<String>(),
192 new_text.chars().take(50).collect::<String>(),
193 line_number
194 ),
195 file_path: file_path.to_string(),
196 line_changed: Some(line_number),
197 old_content: old_text.to_string(),
198 new_content: new_text.to_string(),
199 context_before,
200 context_after,
201 })
202 }
203
204 fn detect_indentation(line: &str) -> String {
206 let mut indent = String::new();
207 for ch in line.chars() {
208 if ch == ' ' || ch == '\t' {
209 indent.push(ch);
210 } else {
211 break;
212 }
213 }
214 indent
215 }
216
217 fn extract_context(lines: &[String], changed_line_index: usize) -> (Vec<String>, Vec<String>) {
219 let before_start = changed_line_index.saturating_sub(3);
220 let before_end = changed_line_index;
221
222 let after_start = (changed_line_index + 1).min(lines.len());
223 let after_end = (after_start + 3).min(lines.len());
224
225 let context_before = lines[before_start..before_end].to_vec();
226 let context_after = lines[after_start..after_end].to_vec();
227
228 (context_before, context_after)
229 }
230}
231
232impl Default for EditTool {
233 fn default() -> Self {
234 Self::new()
235 }
236}
237
238#[derive(Debug, Clone)]
240pub struct AmbiguousMatches {
241 pub matches: Vec<MatchCandidate>,
242}
243
244#[derive(Debug, Clone)]
245pub struct MatchCandidate {
246 pub line_number: usize,
247 pub context: String,
248 pub full_line: String,
249}
250
251impl EditTool {
252 pub fn find_matches(file_path: &str, pattern: &str) -> Result<AmbiguousMatches, EditError> {
254 if !Path::new(file_path).exists() {
255 return Err(EditError::FileNotFound(file_path.to_string()));
256 }
257
258 let content = fs::read_to_string(file_path).map_err(EditError::Io)?;
259 let lines: Vec<&str> = content.lines().collect();
260
261 let mut matches = Vec::new();
262
263 for (line_idx, line) in lines.iter().enumerate() {
264 if line.contains(pattern) {
265 let line_number = line_idx + 1;
266 let context_start = line_idx.saturating_sub(2);
267 let context_end = (line_idx + 3).min(lines.len());
268
269 let context = lines[context_start..context_end]
270 .iter()
271 .enumerate()
272 .map(|(i, l)| {
273 let actual_line = context_start + i + 1;
274 if actual_line == line_number {
275 format!("→ {}: {}", actual_line, l)
276 } else {
277 format!(" {}: {}", actual_line, l)
278 }
279 })
280 .collect::<Vec<_>>()
281 .join("\n");
282
283 matches.push(MatchCandidate {
284 line_number,
285 context,
286 full_line: (*line).to_string(),
287 });
288 }
289 }
290
291 Ok(AmbiguousMatches { matches })
292 }
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use std::fs;
299 use tempfile::NamedTempFile;
300
301 #[test]
302 fn test_edit_line() {
303 let temp_file = NamedTempFile::new().unwrap();
304 let content = "line 1\nline 2\nline 3\n";
305 fs::write(temp_file.path(), content).unwrap();
306
307 let result =
308 EditTool::edit_line(temp_file.path().to_str().unwrap(), 2, "modified line 2").unwrap();
309
310 assert!(result.success);
311 assert_eq!(result.line_changed, Some(2));
312 assert_eq!(result.old_content, "line 2");
313 assert_eq!(result.new_content, "modified line 2");
314
315 let new_content = fs::read_to_string(temp_file.path()).unwrap();
317 assert!(new_content.contains("modified line 2"));
318 }
319
320 #[test]
321 fn test_edit_text_unique() {
322 let temp_file = NamedTempFile::new().unwrap();
323 let content = "hello world\nthis is unique\ngoodbye world\n";
324 fs::write(temp_file.path(), content).unwrap();
325
326 let result = EditTool::edit_text(
327 temp_file.path().to_str().unwrap(),
328 "this is unique",
329 "this is modified",
330 )
331 .unwrap();
332
333 assert!(result.success);
334 assert_eq!(result.line_changed, Some(2));
335
336 let new_content = fs::read_to_string(temp_file.path()).unwrap();
338 assert!(new_content.contains("this is modified"));
339 }
340
341 #[test]
342 fn test_edit_text_ambiguous() {
343 let temp_file = NamedTempFile::new().unwrap();
344 let content = "hello world\nhello again\nhello there\n";
345 fs::write(temp_file.path(), content).unwrap();
346
347 let result = EditTool::edit_text(temp_file.path().to_str().unwrap(), "hello", "hi");
348
349 assert!(matches!(
350 result,
351 Err(EditError::AmbiguousMatch { count: 3, .. })
352 ));
353 }
354
355 #[test]
356 fn test_indentation_preservation() {
357 let temp_file = NamedTempFile::new().unwrap();
358 let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
359 fs::write(temp_file.path(), content).unwrap();
360
361 let result =
362 EditTool::edit_line(temp_file.path().to_str().unwrap(), 2, "let x = 42;").unwrap();
363
364 assert_eq!(result.new_content, " let x = 42;");
365 }
366
367 #[test]
368 fn test_find_matches() {
369 let temp_file = NamedTempFile::new().unwrap();
370 let content = "hello world\nhello again\nhello there\n";
371 fs::write(temp_file.path(), content).unwrap();
372
373 let matches = EditTool::find_matches(temp_file.path().to_str().unwrap(), "hello").unwrap();
374
375 assert_eq!(matches.matches.len(), 3);
376 assert_eq!(matches.matches[0].line_number, 1);
377 assert_eq!(matches.matches[1].line_number, 2);
378 assert_eq!(matches.matches[2].line_number, 3);
379 }
380}