Skip to main content

pawan/tools/
edit.rs

1//! Edit tool for precise string replacement
2
3use super::Tool;
4use super::file::normalize_path;
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::PathBuf;
8
9/// Tool for editing files with precise string replacement
10pub struct EditFileTool {
11    workspace_root: PathBuf,
12}
13
14impl EditFileTool {
15    pub fn new(workspace_root: PathBuf) -> Self {
16        Self { workspace_root }
17    }
18
19    fn resolve_path(&self, path: &str) -> PathBuf {
20        normalize_path(&self.workspace_root, path)
21    }
22}
23
24#[async_trait]
25impl Tool for EditFileTool {
26    fn name(&self) -> &str {
27        "edit_file"
28    }
29
30    fn description(&self) -> &str {
31        "Edit a file by replacing an exact string with new text. \
32         PREFER edit_file_lines for most edits — it is more reliable because it \
33         uses line numbers instead of exact string matching. \
34         Use edit_file only when the target string is short, unique, and trivially \
35         identifiable (e.g. a one-line change in a small file). \
36         Fails if old_string is not found or appears more than once (use replace_all for the latter)."
37    }
38
39    fn parameters_schema(&self) -> Value {
40        json!({
41            "type": "object",
42            "properties": {
43                "path": {
44                    "type": "string",
45                    "description": "Path to the file to edit"
46                },
47                "old_string": {
48                    "type": "string",
49                    "description": "The exact string to find and replace"
50                },
51                "new_string": {
52                    "type": "string",
53                    "description": "The string to replace it with"
54                },
55                "replace_all": {
56                    "type": "boolean",
57                    "description": "Replace all occurrences (default: false)"
58                }
59            },
60            "required": ["path", "old_string", "new_string"]
61        })
62    }
63
64    async fn execute(&self, args: Value) -> crate::Result<Value> {
65        let path = args["path"]
66            .as_str()
67            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
68
69        let old_string = args["old_string"]
70            .as_str()
71            .ok_or_else(|| crate::PawanError::Tool("old_string is required".into()))?;
72
73        let new_string = args["new_string"]
74            .as_str()
75            .ok_or_else(|| crate::PawanError::Tool("new_string is required".into()))?;
76
77        let replace_all = args["replace_all"].as_bool().unwrap_or(false);
78
79        let full_path = self.resolve_path(path);
80
81        if !full_path.exists() {
82            return Err(crate::PawanError::NotFound(format!(
83                "File not found: {}",
84                full_path.display()
85            )));
86        }
87
88        // Read current content
89        let content = tokio::fs::read_to_string(&full_path)
90            .await
91            .map_err(crate::PawanError::Io)?;
92
93        // Count occurrences
94        let occurrence_count = content.matches(old_string).count();
95
96        if occurrence_count == 0 {
97            return Err(crate::PawanError::Tool(
98                "old_string not found in file. Make sure the string matches exactly including whitespace.".to_string()
99            ));
100        }
101
102        if occurrence_count > 1 && !replace_all {
103            return Err(crate::PawanError::Tool(format!(
104                "old_string found {} times. Use replace_all: true to replace all, \
105                 or provide more context to make the match unique.",
106                occurrence_count
107            )));
108        }
109
110        // Perform replacement
111        let new_content = if replace_all {
112            content.replace(old_string, new_string)
113        } else {
114            content.replacen(old_string, new_string, 1)
115        };
116
117        // Write back
118        tokio::fs::write(&full_path, &new_content)
119            .await
120            .map_err(crate::PawanError::Io)?;
121
122        // Generate a diff preview
123        let diff = generate_diff(&content, &new_content, path);
124
125        Ok(json!({
126            "success": true,
127            "path": full_path.display().to_string(),
128            "replacements": if replace_all { occurrence_count } else { 1 },
129            "diff": diff
130        }))
131    }
132}
133
134/// Tool for editing files by replacing a range of lines
135pub struct EditFileLinesTool {
136    workspace_root: PathBuf,
137}
138
139impl EditFileLinesTool {
140    pub fn new(workspace_root: PathBuf) -> Self {
141        Self { workspace_root }
142    }
143
144    fn resolve_path(&self, path: &str) -> PathBuf {
145        normalize_path(&self.workspace_root, path)
146    }
147}
148
149#[async_trait]
150impl Tool for EditFileLinesTool {
151    fn name(&self) -> &str {
152        "edit_file_lines"
153    }
154
155    fn description(&self) -> &str {
156        "PREFERRED edit tool. Replace lines in a file. Two modes:\n\
157         Mode 1 (line numbers): pass start_line + end_line (1-based, inclusive).\n\
158         Mode 2 (anchor — MORE RELIABLE): pass anchor_text + anchor_count instead of line numbers. \
159         The tool finds the line containing anchor_text, then replaces anchor_count lines starting from that line.\n\
160         Always prefer Mode 2 (anchor) to avoid line-number miscounting.\n\
161         Set new_content to \"\" to delete lines."
162    }
163
164    fn parameters_schema(&self) -> Value {
165        json!({
166            "type": "object",
167            "properties": {
168                "path": {
169                    "type": "string",
170                    "description": "Path to the file to edit"
171                },
172                "start_line": {
173                    "type": "integer",
174                    "description": "First line to replace (1-based, inclusive). Optional if anchor_text is provided."
175                },
176                "end_line": {
177                    "type": "integer",
178                    "description": "Last line to replace (1-based, inclusive). Optional if anchor_text is provided."
179                },
180                "anchor_text": {
181                    "type": "string",
182                    "description": "PREFERRED: unique text that appears on the first line to replace. The tool finds this line automatically — no line-number math needed."
183                },
184                "anchor_count": {
185                    "type": "integer",
186                    "description": "Number of lines to replace starting from the anchor line (default: 1). Only used with anchor_text."
187                },
188                "new_content": {
189                    "type": "string",
190                    "description": "Replacement text for the specified lines. Empty string to delete lines."
191                }
192            },
193            "required": ["path", "new_content"]
194        })
195    }
196
197    async fn execute(&self, args: Value) -> crate::Result<Value> {
198        let path = args["path"]
199            .as_str()
200            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
201
202        let full_path = self.resolve_path(path);
203        if !full_path.exists() {
204            return Err(crate::PawanError::NotFound(format!(
205                "File not found: {}", full_path.display()
206            )));
207        }
208
209        let content = tokio::fs::read_to_string(&full_path)
210            .await
211            .map_err(crate::PawanError::Io)?;
212
213        let had_trailing_newline = content.ends_with('\n');
214        let lines: Vec<&str> = content.lines().collect();
215        let total_lines = lines.len();
216
217        // Resolve start_line and end_line — either from explicit numbers or anchor
218        let (start_line, end_line) = if let Some(anchor) = args["anchor_text"].as_str() {
219            // Anchor mode: find line containing anchor_text
220            // Fuzzy matching: normalize whitespace for comparison
221            let anchor_count = args["anchor_count"].as_u64().unwrap_or(1) as usize;
222            let anchor_normalized: String = anchor.split_whitespace().collect::<Vec<_>>().join(" ");
223            let found = lines.iter().position(|l| {
224                // Try exact match first
225                if l.contains(anchor) { return true; }
226                // Then try whitespace-normalized match
227                let line_normalized: String = l.split_whitespace().collect::<Vec<_>>().join(" ");
228                line_normalized.contains(&anchor_normalized)
229            });
230            match found {
231                Some(idx) => {
232                    let start = idx + 1; // convert to 1-based
233                    let end = (start + anchor_count - 1).min(total_lines);
234                    (start, end)
235                }
236                None => {
237                    // Try case-insensitive as last resort
238                    let anchor_lower = anchor_normalized.to_lowercase();
239                    let found_ci = lines.iter().position(|l| {
240                        let norm: String = l.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase();
241                        norm.contains(&anchor_lower)
242                    });
243                    match found_ci {
244                        Some(idx) => {
245                            let start = idx + 1;
246                            let end = (start + anchor_count - 1).min(total_lines);
247                            (start, end)
248                        }
249                        None => {
250                            return Err(crate::PawanError::Tool(format!(
251                                "anchor_text {:?} not found in file ({} lines). Try a shorter or different anchor string.",
252                                anchor, total_lines
253                            )));
254                        }
255                    }
256                }
257            }
258        } else {
259            // Line number mode
260            let start = args["start_line"]
261                .as_u64()
262                .ok_or_else(|| crate::PawanError::Tool(
263                    "Either anchor_text or start_line+end_line is required".into()
264                ))? as usize;
265            let end = args["end_line"]
266                .as_u64()
267                .ok_or_else(|| crate::PawanError::Tool("end_line is required".into()))? as usize;
268            (start, end)
269        };
270
271        let new_content = args["new_content"]
272            .as_str()
273            .ok_or_else(|| crate::PawanError::Tool("new_content is required".into()))?;
274
275        if start_line == 0 {
276            return Err(crate::PawanError::Tool(
277                "start_line must be >= 1 (lines are 1-based)".into(),
278            ));
279        }
280
281        if end_line < start_line {
282            return Err(crate::PawanError::Tool(format!(
283                "end_line ({end_line}) must be >= start_line ({start_line})"
284            )));
285        }
286
287        if start_line > total_lines {
288            return Err(crate::PawanError::Tool(format!(
289                "start_line ({start_line}) exceeds file length ({total_lines} lines). \
290                 TIP: use anchor_text instead of line numbers to avoid this error."
291            )));
292        }
293
294        if end_line > total_lines {
295            return Err(crate::PawanError::Tool(format!(
296                "end_line ({end_line}) exceeds file length ({total_lines} lines). \
297                 TIP: use anchor_text instead of line numbers to avoid this error."
298            )));
299        }
300
301        let new_lines: Vec<&str> = new_content.lines().collect();
302        let lines_replaced = end_line - start_line + 1;
303
304        // Context echo: capture what's being replaced (helps LLM verify correctness)
305        let replaced_lines: Vec<String> = lines[start_line - 1..end_line]
306            .iter()
307            .enumerate()
308            .map(|(i, l)| format!("{:>4} | {}", start_line + i, l))
309            .collect();
310        let replaced_preview = replaced_lines.join("\n");
311
312        let before = &lines[..start_line - 1];
313        let after = &lines[end_line..];
314
315        let mut result_lines: Vec<&str> =
316            Vec::with_capacity(before.len() + new_lines.len() + after.len());
317        result_lines.extend_from_slice(before);
318        result_lines.extend_from_slice(&new_lines);
319        result_lines.extend_from_slice(after);
320
321        let mut new_content_str = result_lines.join("\n");
322        if had_trailing_newline && !new_content_str.is_empty() {
323            new_content_str.push('\n');
324        }
325
326        tokio::fs::write(&full_path, &new_content_str)
327            .await
328            .map_err(crate::PawanError::Io)?;
329
330        let diff = generate_diff(&content, &new_content_str, path);
331
332        Ok(json!({
333            "success": true,
334            "path": full_path.display().to_string(),
335            "lines_replaced": lines_replaced,
336            "new_line_count": new_lines.len(),
337            "replaced_content": replaced_preview,
338            "diff": diff
339        }))
340    }
341}
342
343/// Generate a simple diff between two strings
344fn generate_diff(old: &str, new: &str, filename: &str) -> String {
345    use similar::{ChangeTag, TextDiff};
346
347    let diff = TextDiff::from_lines(old, new);
348    let mut result = String::new();
349
350    result.push_str(&format!("--- a/{}\n", filename));
351    result.push_str(&format!("+++ b/{}\n", filename));
352
353    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
354        if idx > 0 {
355            result.push_str("...\n");
356        }
357
358        for op in group {
359            for change in diff.iter_changes(op) {
360                let sign = match change.tag() {
361                    ChangeTag::Delete => "-",
362                    ChangeTag::Insert => "+",
363                    ChangeTag::Equal => " ",
364                };
365                result.push_str(&format!("{}{}", sign, change));
366            }
367        }
368    }
369
370    result
371}
372
373/// Tool for inserting text after a line matching a pattern.
374/// Safer than edit_file_lines for additions — never replaces existing content.
375pub struct InsertAfterTool {
376/// Tool for inserting text after a line matching a pattern.
377    workspace_root: PathBuf,
378}
379
380impl InsertAfterTool {
381    pub fn new(workspace_root: PathBuf) -> Self {
382        Self { workspace_root }
383    }
384
385    fn resolve_path(&self, path: &str) -> PathBuf {
386        normalize_path(&self.workspace_root, path)
387    }
388}
389
390#[async_trait]
391impl Tool for InsertAfterTool {
392    fn name(&self) -> &str {
393        "insert_after"
394    }
395
396    fn description(&self) -> &str {
397        "Insert text after a line matching a pattern. Finds the FIRST line containing \
398         the anchor text. If that line opens a block (ends with '{'), inserts AFTER the \
399         closing '}' of that block — safe for functions, structs, impls. Otherwise inserts \
400         on the next line. Does not replace anything. Use for adding new code."
401    }
402
403    fn parameters_schema(&self) -> Value {
404        json!({
405            "type": "object",
406            "properties": {
407                "path": { "type": "string", "description": "Path to the file" },
408                "anchor_text": { "type": "string", "description": "Text to find — insertion happens AFTER this line" },
409                "content": { "type": "string", "description": "Text to insert after the anchor line" }
410            },
411            "required": ["path", "anchor_text", "content"]
412        })
413    }
414
415    async fn execute(&self, args: Value) -> crate::Result<Value> {
416        let path = args["path"].as_str()
417            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
418        let anchor = args["anchor_text"].as_str()
419            .ok_or_else(|| crate::PawanError::Tool("anchor_text is required".into()))?;
420        let insert_content = args["content"].as_str()
421            .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
422
423        let full_path = self.resolve_path(path);
424        if !full_path.exists() {
425            return Err(crate::PawanError::NotFound(format!("File not found: {}", full_path.display())));
426        }
427
428        let content = tokio::fs::read_to_string(&full_path).await.map_err(crate::PawanError::Io)?;
429        let had_trailing_newline = content.ends_with('\n');
430        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
431
432        // Fuzzy anchor matching: exact → whitespace-normalized → case-insensitive
433        let anchor_normalized: String = anchor.split_whitespace().collect::<Vec<_>>().join(" ");
434        let found = lines.iter().position(|l| {
435            if l.contains(anchor) { return true; }
436            let norm: String = l.split_whitespace().collect::<Vec<_>>().join(" ");
437            norm.contains(&anchor_normalized) || norm.to_lowercase().contains(&anchor_normalized.to_lowercase())
438        });
439        match found {
440            Some(idx) => {
441                let insert_lines: Vec<String> = insert_content.lines().map(|l| l.to_string()).collect();
442                let insert_count = insert_lines.len();
443
444                // Smart insertion: if anchor line opens a block ({), insert AFTER the block closes
445                let anchor_line = &lines[idx];
446                let insert_at = if anchor_line.trim_end().ends_with('{') {
447                    // Find matching closing brace
448                    let mut depth = 0i32;
449                    let mut close_idx = idx;
450                    for (i, line) in lines.iter().enumerate().skip(idx) {
451                        for ch in line.chars() {
452                            if ch == '{' { depth += 1; }
453                            if ch == '}' { depth -= 1; }
454                        }
455                        if depth == 0 {
456                            close_idx = i;
457                            break;
458                        }
459                    }
460                    close_idx + 1
461                } else {
462                    idx + 1
463                };
464                for (i, line) in insert_lines.into_iter().enumerate() {
465                    lines.insert(insert_at + i, line);
466                }
467                let mut new_content = lines.join("\n");
468                if had_trailing_newline { new_content.push('\n'); }
469                let diff = generate_diff(&content, &new_content, path);
470                tokio::fs::write(&full_path, &new_content).await.map_err(crate::PawanError::Io)?;
471                let block_skipped = insert_at != idx + 1;
472                Ok(json!({
473                    "success": true,
474                    "path": full_path.display().to_string(),
475                    "anchor_line": idx + 1,
476                    "inserted_after_line": insert_at,
477                    "block_skipped": block_skipped,
478                    "block_skip_note": if block_skipped { format!("Anchor line {} opens a block — inserted after closing '}}' at line {}", idx + 1, insert_at) } else { String::new() },
479                    "lines_inserted": insert_count,
480                    "anchor_matched": lines.get(idx).unwrap_or(&String::new()).trim(),
481                    "diff": diff
482                }))
483            }
484            None => Err(crate::PawanError::Tool(format!(
485                "anchor_text {:?} not found in file", anchor
486            ))),
487        }
488    }
489}
490
491/// Tool for appending content to the end of a file
492pub struct AppendFileTool {
493    workspace_root: PathBuf,
494}
495
496impl AppendFileTool {
497    pub fn new(workspace_root: PathBuf) -> Self {
498        Self { workspace_root }
499    }
500
501    fn resolve_path(&self, path: &str) -> PathBuf {
502        normalize_path(&self.workspace_root, path)
503    }
504}
505
506#[async_trait]
507impl Tool for AppendFileTool {
508    fn name(&self) -> &str {
509        "append_file"
510    }
511
512    fn description(&self) -> &str {
513        "Append content to the end of a file. Creates the file if it doesn't exist. \
514         Use for adding new functions, tests, or sections without touching existing content. \
515         Safer than write_file for large additions."
516    }
517
518    fn parameters_schema(&self) -> Value {
519        json!({
520            "type": "object",
521            "properties": {
522                "path": { "type": "string", "description": "Path to the file" },
523                "content": { "type": "string", "description": "Content to append" }
524            },
525            "required": ["path", "content"]
526        })
527    }
528
529    async fn execute(&self, args: Value) -> crate::Result<Value> {
530        let path = args["path"].as_str()
531            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
532        let append_content = args["content"].as_str()
533            .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
534
535        let full_path = self.resolve_path(path);
536        if let Some(parent) = full_path.parent() {
537            tokio::fs::create_dir_all(parent).await.map_err(crate::PawanError::Io)?;
538        }
539
540        let existing = if full_path.exists() {
541            tokio::fs::read_to_string(&full_path).await.map_err(crate::PawanError::Io)?
542        } else {
543            String::new()
544        };
545
546        let separator = if existing.is_empty() || existing.ends_with('\n') { "" } else { "\n" };
547        let new_content = format!("{}{}{}\n", existing, separator, append_content);
548        let appended_lines = append_content.lines().count();
549
550        tokio::fs::write(&full_path, &new_content).await.map_err(crate::PawanError::Io)?;
551
552        Ok(json!({
553            "success": true,
554            "path": full_path.display().to_string(),
555            "lines_appended": appended_lines,
556            "total_lines": new_content.lines().count()
557        }))
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564    use tempfile::TempDir;
565
566    #[tokio::test]
567    async fn test_edit_file_single_replacement() {
568        let temp_dir = TempDir::new().unwrap();
569        let file_path = temp_dir.path().join("test.rs");
570        std::fs::write(&file_path, "fn main() {\n    println!(\"Hello\");\n}").unwrap();
571
572        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
573        let result = tool
574            .execute(json!({
575                "path": "test.rs",
576                "old_string": "println!(\"Hello\")",
577                "new_string": "println!(\"Hello, World!\")"
578            }))
579            .await
580            .unwrap();
581
582        assert!(result["success"].as_bool().unwrap());
583        assert_eq!(result["replacements"], 1);
584
585        let new_content = std::fs::read_to_string(&file_path).unwrap();
586        assert!(new_content.contains("Hello, World!"));
587    }
588
589    #[tokio::test]
590    async fn test_edit_file_not_found() {
591        let temp_dir = TempDir::new().unwrap();
592        let file_path = temp_dir.path().join("test.rs");
593        std::fs::write(&file_path, "fn main() {}").unwrap();
594
595        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
596        let result = tool
597            .execute(json!({
598                "path": "test.rs",
599                "old_string": "nonexistent",
600                "new_string": "replacement"
601            }))
602            .await;
603
604        assert!(result.is_err());
605    }
606
607    #[tokio::test]
608    async fn test_edit_file_multiple_without_replace_all() {
609        let temp_dir = TempDir::new().unwrap();
610        let file_path = temp_dir.path().join("test.rs");
611        std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
612
613        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
614        let result = tool
615            .execute(json!({
616                "path": "test.rs",
617                "old_string": "let x",
618                "new_string": "let y"
619            }))
620            .await;
621
622        // Should fail because there are multiple occurrences
623        assert!(result.is_err());
624    }
625
626    #[tokio::test]
627    async fn test_edit_file_replace_all() {
628        let temp_dir = TempDir::new().unwrap();
629        let file_path = temp_dir.path().join("test.rs");
630        std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
631
632        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
633        let result = tool
634            .execute(json!({
635                "path": "test.rs",
636                "old_string": "let x",
637                "new_string": "let y",
638                "replace_all": true
639            }))
640            .await
641            .unwrap();
642
643        assert!(result["success"].as_bool().unwrap());
644        assert_eq!(result["replacements"], 2);
645
646        let new_content = std::fs::read_to_string(&file_path).unwrap();
647        assert!(!new_content.contains("let x"));
648        assert!(new_content.contains("let y"));
649    }
650
651    // --- EditFileLinesTool tests ---
652
653    #[tokio::test]
654    async fn test_edit_file_lines_middle() {
655        let temp_dir = TempDir::new().unwrap();
656        let file_path = temp_dir.path().join("test.rs");
657        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
658
659        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
660        let result = tool
661            .execute(json!({
662                "path": "test.rs",
663                "start_line": 2,
664                "end_line": 2,
665                "new_content": "replaced"
666            }))
667            .await
668            .unwrap();
669
670        assert!(result["success"].as_bool().unwrap());
671        assert_eq!(result["lines_replaced"], 1);
672        let content = std::fs::read_to_string(&file_path).unwrap();
673        assert_eq!(content, "line1\nreplaced\nline3\n");
674    }
675
676    #[tokio::test]
677    async fn test_edit_file_lines_first() {
678        let temp_dir = TempDir::new().unwrap();
679        let file_path = temp_dir.path().join("test.rs");
680        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
681
682        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
683        let result = tool
684            .execute(json!({
685                "path": "test.rs",
686                "start_line": 1,
687                "end_line": 1,
688                "new_content": "new_line1"
689            }))
690            .await
691            .unwrap();
692
693        assert!(result["success"].as_bool().unwrap());
694        let content = std::fs::read_to_string(&file_path).unwrap();
695        assert_eq!(content, "new_line1\nline2\nline3\n");
696    }
697
698    #[tokio::test]
699    async fn test_edit_file_lines_last() {
700        let temp_dir = TempDir::new().unwrap();
701        let file_path = temp_dir.path().join("test.rs");
702        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
703
704        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
705        let result = tool
706            .execute(json!({
707                "path": "test.rs",
708                "start_line": 3,
709                "end_line": 3,
710                "new_content": "new_line3"
711            }))
712            .await
713            .unwrap();
714
715        assert!(result["success"].as_bool().unwrap());
716        let content = std::fs::read_to_string(&file_path).unwrap();
717        assert_eq!(content, "line1\nline2\nnew_line3\n");
718    }
719
720    #[tokio::test]
721    async fn test_edit_file_lines_multi_line_replacement() {
722        let temp_dir = TempDir::new().unwrap();
723        let file_path = temp_dir.path().join("test.rs");
724        std::fs::write(&file_path, "fn foo() {\n    old();\n}\n").unwrap();
725
726        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
727        let result = tool
728            .execute(json!({
729                "path": "test.rs",
730                "start_line": 1,
731                "end_line": 3,
732                "new_content": "fn foo() {\n    new_a();\n    new_b();\n}"
733            }))
734            .await
735            .unwrap();
736
737        assert!(result["success"].as_bool().unwrap());
738        assert_eq!(result["lines_replaced"], 3);
739        assert_eq!(result["new_line_count"], 4);
740        let content = std::fs::read_to_string(&file_path).unwrap();
741        assert!(content.contains("new_a()"));
742        assert!(content.contains("new_b()"));
743        assert!(!content.contains("old()"));
744    }
745
746    #[tokio::test]
747    async fn test_edit_file_lines_delete() {
748        let temp_dir = TempDir::new().unwrap();
749        let file_path = temp_dir.path().join("test.rs");
750        std::fs::write(&file_path, "line1\ndelete_me\nline3\n").unwrap();
751
752        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
753        let result = tool
754            .execute(json!({
755                "path": "test.rs",
756                "start_line": 2,
757                "end_line": 2,
758                "new_content": ""
759            }))
760            .await
761            .unwrap();
762
763        assert!(result["success"].as_bool().unwrap());
764        let content = std::fs::read_to_string(&file_path).unwrap();
765        assert_eq!(content, "line1\nline3\n");
766    }
767
768    #[tokio::test]
769    async fn test_edit_file_lines_out_of_bounds() {
770        let temp_dir = TempDir::new().unwrap();
771        let file_path = temp_dir.path().join("test.rs");
772        std::fs::write(&file_path, "line1\nline2\n").unwrap();
773
774        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
775        let result = tool
776            .execute(json!({
777                "path": "test.rs",
778                "start_line": 5,
779                "end_line": 5,
780                "new_content": "x"
781            }))
782            .await;
783
784        assert!(result.is_err());
785    }
786
787    #[tokio::test]
788    async fn test_edit_file_lines_end_before_start() {
789        let temp_dir = TempDir::new().unwrap();
790        let file_path = temp_dir.path().join("test.rs");
791        std::fs::write(&file_path, "line1\nline2\n").unwrap();
792
793        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
794        let result = tool
795            .execute(json!({
796                "path": "test.rs",
797                "start_line": 2,
798                "end_line": 1,
799                "new_content": "x"
800            }))
801            .await;
802
803        assert!(result.is_err());
804    }
805
806    #[tokio::test]
807    async fn test_edit_file_lines_preserves_no_trailing_newline() {
808        let temp_dir = TempDir::new().unwrap();
809        let file_path = temp_dir.path().join("test.rs");
810        // File without trailing newline
811        std::fs::write(&file_path, "line1\nline2").unwrap();
812
813        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
814        tool.execute(json!({
815            "path": "test.rs",
816            "start_line": 1,
817            "end_line": 1,
818            "new_content": "replaced"
819        }))
820        .await
821        .unwrap();
822
823        let content = std::fs::read_to_string(&file_path).unwrap();
824        assert_eq!(content, "replaced\nline2");
825    }
826}