agcodex_core/tools/
edit.rs

1//! Simple, practical edit tool for AGCodex.
2//!
3//! Provides reliable line and text editing with LLM-friendly output.
4//! Focuses on predictability over sophistication.
5
6use 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/// LLM-friendly result of an edit operation
32#[derive(Debug, Clone)]
33pub struct EditResult {
34    pub success: bool,
35    pub message: String, // Human & LLM readable description
36    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>, // 3 lines before
41    pub context_after: Vec<String>,  // 3 lines after
42}
43
44/// Simple edit tool with practical API
45pub struct EditTool {
46    // Empty for now - all methods are static
47}
48
49impl EditTool {
50    pub const fn new() -> Self {
51        Self {}
52    }
53
54    /// Edit a specific line number in a file
55    pub fn edit_line(
56        file_path: &str,
57        line_number: usize,
58        new_content: &str,
59    ) -> Result<EditResult, EditError> {
60        // Validate file exists
61        if !Path::new(file_path).exists() {
62            return Err(EditError::FileNotFound(file_path.to_string()));
63        }
64
65        // Read file content
66        let content = fs::read_to_string(file_path)
67            .map_err(EditError::Io)?
68            .replace('\r', ""); // Normalize line endings
69
70        // Validate UTF-8
71        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        // Validate line number (1-based)
78        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        // Get context before change
86        let line_index = line_number - 1;
87        let old_content = lines[line_index].clone();
88
89        // Preserve indentation automatically
90        let indentation = Self::detect_indentation(&old_content);
91        let new_content_with_indent = if new_content.trim().is_empty() {
92            String::new() // Empty line
93        } else {
94            format!("{}{}", indentation, new_content.trim())
95        };
96
97        // Make the change
98        lines[line_index] = new_content_with_indent.clone();
99
100        // Create backup
101        let backup_path = format!("{}.backup", file_path);
102        fs::write(&backup_path, &content).map_err(EditError::Io)?;
103
104        // Write atomically
105        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        // Extract context
111        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    /// Replace specific text in a file
131    pub fn edit_text(
132        file_path: &str,
133        old_text: &str,
134        new_text: &str,
135    ) -> Result<EditResult, EditError> {
136        // Validate file exists
137        if !Path::new(file_path).exists() {
138            return Err(EditError::FileNotFound(file_path.to_string()));
139        }
140
141        // Read file content
142        let content = fs::read_to_string(file_path)
143            .map_err(EditError::Io)?
144            .replace('\r', ""); // Normalize line endings
145
146        // Validate UTF-8
147        if content.chars().any(|c| c == '\u{FFFD}') {
148            return Err(EditError::InvalidUtf8);
149        }
150
151        // Find all matches
152        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 ambiguous, return all candidates with context
159        if matches.len() > 1 {
160            return Err(EditError::AmbiguousMatch {
161                pattern: old_text.to_string(),
162                count: matches.len(),
163            });
164        }
165
166        // Make the replacement
167        let new_content = content.replace(old_text, new_text);
168
169        // Find which line was changed
170        let match_pos = matches[0].0;
171        let line_number = content[..match_pos].matches('\n').count() + 1;
172
173        // Create backup
174        let backup_path = format!("{}.backup", file_path);
175        fs::write(&backup_path, &content).map_err(EditError::Io)?;
176
177        // Write atomically
178        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        // Extract context
183        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    /// Detect indentation from existing line
205    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    /// Extract 3 lines before and after for context
218    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/// Show all ambiguous matches with context for user selection
239#[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    /// Find all matches of a pattern with context (for handling ambiguity)
253    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        // Verify file was changed
316        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        // Verify file was changed
337        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}