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            let anchor_count = args["anchor_count"].as_u64().unwrap_or(1) as usize;
221            let found = lines.iter().position(|l| l.contains(anchor));
222            match found {
223                Some(idx) => {
224                    let start = idx + 1; // convert to 1-based
225                    let end = (start + anchor_count - 1).min(total_lines);
226                    (start, end)
227                }
228                None => {
229                    return Err(crate::PawanError::Tool(format!(
230                        "anchor_text {:?} not found in file ({} lines). Try a different anchor string.",
231                        anchor, total_lines
232                    )));
233                }
234            }
235        } else {
236            // Line number mode
237            let start = args["start_line"]
238                .as_u64()
239                .ok_or_else(|| crate::PawanError::Tool(
240                    "Either anchor_text or start_line+end_line is required".into()
241                ))? as usize;
242            let end = args["end_line"]
243                .as_u64()
244                .ok_or_else(|| crate::PawanError::Tool("end_line is required".into()))? as usize;
245            (start, end)
246        };
247
248        let new_content = args["new_content"]
249            .as_str()
250            .ok_or_else(|| crate::PawanError::Tool("new_content is required".into()))?;
251
252        if start_line == 0 {
253            return Err(crate::PawanError::Tool(
254                "start_line must be >= 1 (lines are 1-based)".into(),
255            ));
256        }
257
258        if end_line < start_line {
259            return Err(crate::PawanError::Tool(format!(
260                "end_line ({end_line}) must be >= start_line ({start_line})"
261            )));
262        }
263
264        if start_line > total_lines {
265            return Err(crate::PawanError::Tool(format!(
266                "start_line ({start_line}) exceeds file length ({total_lines} lines). \
267                 TIP: use anchor_text instead of line numbers to avoid this error."
268            )));
269        }
270
271        if end_line > total_lines {
272            return Err(crate::PawanError::Tool(format!(
273                "end_line ({end_line}) exceeds file length ({total_lines} lines). \
274                 TIP: use anchor_text instead of line numbers to avoid this error."
275            )));
276        }
277
278        let new_lines: Vec<&str> = new_content.lines().collect();
279        let lines_replaced = end_line - start_line + 1;
280
281        // Context echo: capture what's being replaced (helps LLM verify correctness)
282        let replaced_lines: Vec<String> = lines[start_line - 1..end_line]
283            .iter()
284            .enumerate()
285            .map(|(i, l)| format!("{:>4} | {}", start_line + i, l))
286            .collect();
287        let replaced_preview = replaced_lines.join("\n");
288
289        let before = &lines[..start_line - 1];
290        let after = &lines[end_line..];
291
292        let mut result_lines: Vec<&str> =
293            Vec::with_capacity(before.len() + new_lines.len() + after.len());
294        result_lines.extend_from_slice(before);
295        result_lines.extend_from_slice(&new_lines);
296        result_lines.extend_from_slice(after);
297
298        let mut new_content_str = result_lines.join("\n");
299        if had_trailing_newline && !new_content_str.is_empty() {
300            new_content_str.push('\n');
301        }
302
303        tokio::fs::write(&full_path, &new_content_str)
304            .await
305            .map_err(crate::PawanError::Io)?;
306
307        let diff = generate_diff(&content, &new_content_str, path);
308
309        Ok(json!({
310            "success": true,
311            "path": full_path.display().to_string(),
312            "lines_replaced": lines_replaced,
313            "new_line_count": new_lines.len(),
314            "replaced_content": replaced_preview,
315            "diff": diff
316        }))
317    }
318}
319
320/// Generate a simple diff between two strings
321fn generate_diff(old: &str, new: &str, filename: &str) -> String {
322    use similar::{ChangeTag, TextDiff};
323
324    let diff = TextDiff::from_lines(old, new);
325    let mut result = String::new();
326
327    result.push_str(&format!("--- a/{}\n", filename));
328    result.push_str(&format!("+++ b/{}\n", filename));
329
330    for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
331        if idx > 0 {
332            result.push_str("...\n");
333        }
334
335        for op in group {
336            for change in diff.iter_changes(op) {
337                let sign = match change.tag() {
338                    ChangeTag::Delete => "-",
339                    ChangeTag::Insert => "+",
340                    ChangeTag::Equal => " ",
341                };
342                result.push_str(&format!("{}{}", sign, change));
343            }
344        }
345    }
346
347    result
348}
349
350/// Tool for inserting text after a line matching a pattern.
351/// Safer than edit_file_lines for additions — never replaces existing content.
352pub struct InsertAfterTool {
353/// Tool for inserting text after a line matching a pattern.
354    workspace_root: PathBuf,
355}
356
357impl InsertAfterTool {
358    pub fn new(workspace_root: PathBuf) -> Self {
359        Self { workspace_root }
360    }
361
362    fn resolve_path(&self, path: &str) -> PathBuf {
363        normalize_path(&self.workspace_root, path)
364    }
365}
366
367#[async_trait]
368impl Tool for InsertAfterTool {
369    fn name(&self) -> &str {
370        "insert_after"
371    }
372
373    fn description(&self) -> &str {
374        "Insert text after a line matching a pattern. Finds the FIRST line containing \
375         the anchor text. If that line opens a block (ends with '{'), inserts AFTER the \
376         closing '}' of that block — safe for functions, structs, impls. Otherwise inserts \
377         on the next line. Does not replace anything. Use for adding new code."
378    }
379
380    fn parameters_schema(&self) -> Value {
381        json!({
382            "type": "object",
383            "properties": {
384                "path": { "type": "string", "description": "Path to the file" },
385                "anchor_text": { "type": "string", "description": "Text to find — insertion happens AFTER this line" },
386                "content": { "type": "string", "description": "Text to insert after the anchor line" }
387            },
388            "required": ["path", "anchor_text", "content"]
389        })
390    }
391
392    async fn execute(&self, args: Value) -> crate::Result<Value> {
393        let path = args["path"].as_str()
394            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
395        let anchor = args["anchor_text"].as_str()
396            .ok_or_else(|| crate::PawanError::Tool("anchor_text is required".into()))?;
397        let insert_content = args["content"].as_str()
398            .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
399
400        let full_path = self.resolve_path(path);
401        if !full_path.exists() {
402            return Err(crate::PawanError::NotFound(format!("File not found: {}", full_path.display())));
403        }
404
405        let content = tokio::fs::read_to_string(&full_path).await.map_err(crate::PawanError::Io)?;
406        let had_trailing_newline = content.ends_with('\n');
407        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
408
409        let found = lines.iter().position(|l| l.contains(anchor));
410        match found {
411            Some(idx) => {
412                let insert_lines: Vec<String> = insert_content.lines().map(|l| l.to_string()).collect();
413                let insert_count = insert_lines.len();
414
415                // Smart insertion: if anchor line opens a block ({), insert AFTER the block closes
416                let anchor_line = &lines[idx];
417                let insert_at = if anchor_line.trim_end().ends_with('{') {
418                    // Find matching closing brace
419                    let mut depth = 0i32;
420                    let mut close_idx = idx;
421                    for (i, line) in lines.iter().enumerate().skip(idx) {
422                        for ch in line.chars() {
423                            if ch == '{' { depth += 1; }
424                            if ch == '}' { depth -= 1; }
425                        }
426                        if depth == 0 {
427                            close_idx = i;
428                            break;
429                        }
430                    }
431                    close_idx + 1
432                } else {
433                    idx + 1
434                };
435                for (i, line) in insert_lines.into_iter().enumerate() {
436                    lines.insert(insert_at + i, line);
437                }
438                let mut new_content = lines.join("\n");
439                if had_trailing_newline { new_content.push('\n'); }
440                let diff = generate_diff(&content, &new_content, path);
441                tokio::fs::write(&full_path, &new_content).await.map_err(crate::PawanError::Io)?;
442                let block_skipped = insert_at != idx + 1;
443                Ok(json!({
444                    "success": true,
445                    "path": full_path.display().to_string(),
446                    "anchor_line": idx + 1,
447                    "inserted_after_line": insert_at,
448                    "block_skipped": block_skipped,
449                    "block_skip_note": if block_skipped { format!("Anchor line {} opens a block — inserted after closing '}}' at line {}", idx + 1, insert_at) } else { String::new() },
450                    "lines_inserted": insert_count,
451                    "anchor_matched": lines.get(idx).unwrap_or(&String::new()).trim(),
452                    "diff": diff
453                }))
454            }
455            None => Err(crate::PawanError::Tool(format!(
456                "anchor_text {:?} not found in file", anchor
457            ))),
458        }
459    }
460}
461
462/// Tool for appending content to the end of a file
463pub struct AppendFileTool {
464    workspace_root: PathBuf,
465}
466
467impl AppendFileTool {
468    pub fn new(workspace_root: PathBuf) -> Self {
469        Self { workspace_root }
470    }
471
472    fn resolve_path(&self, path: &str) -> PathBuf {
473        normalize_path(&self.workspace_root, path)
474    }
475}
476
477#[async_trait]
478impl Tool for AppendFileTool {
479    fn name(&self) -> &str {
480        "append_file"
481    }
482
483    fn description(&self) -> &str {
484        "Append content to the end of a file. Creates the file if it doesn't exist. \
485         Use for adding new functions, tests, or sections without touching existing content. \
486         Safer than write_file for large additions."
487    }
488
489    fn parameters_schema(&self) -> Value {
490        json!({
491            "type": "object",
492            "properties": {
493                "path": { "type": "string", "description": "Path to the file" },
494                "content": { "type": "string", "description": "Content to append" }
495            },
496            "required": ["path", "content"]
497        })
498    }
499
500    async fn execute(&self, args: Value) -> crate::Result<Value> {
501        let path = args["path"].as_str()
502            .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
503        let append_content = args["content"].as_str()
504            .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
505
506        let full_path = self.resolve_path(path);
507        if let Some(parent) = full_path.parent() {
508            tokio::fs::create_dir_all(parent).await.map_err(crate::PawanError::Io)?;
509        }
510
511        let existing = if full_path.exists() {
512            tokio::fs::read_to_string(&full_path).await.map_err(crate::PawanError::Io)?
513        } else {
514            String::new()
515        };
516
517        let separator = if existing.is_empty() || existing.ends_with('\n') { "" } else { "\n" };
518        let new_content = format!("{}{}{}\n", existing, separator, append_content);
519        let appended_lines = append_content.lines().count();
520
521        tokio::fs::write(&full_path, &new_content).await.map_err(crate::PawanError::Io)?;
522
523        Ok(json!({
524            "success": true,
525            "path": full_path.display().to_string(),
526            "lines_appended": appended_lines,
527            "total_lines": new_content.lines().count()
528        }))
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use tempfile::TempDir;
536
537    #[tokio::test]
538    async fn test_edit_file_single_replacement() {
539        let temp_dir = TempDir::new().unwrap();
540        let file_path = temp_dir.path().join("test.rs");
541        std::fs::write(&file_path, "fn main() {\n    println!(\"Hello\");\n}").unwrap();
542
543        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
544        let result = tool
545            .execute(json!({
546                "path": "test.rs",
547                "old_string": "println!(\"Hello\")",
548                "new_string": "println!(\"Hello, World!\")"
549            }))
550            .await
551            .unwrap();
552
553        assert!(result["success"].as_bool().unwrap());
554        assert_eq!(result["replacements"], 1);
555
556        let new_content = std::fs::read_to_string(&file_path).unwrap();
557        assert!(new_content.contains("Hello, World!"));
558    }
559
560    #[tokio::test]
561    async fn test_edit_file_not_found() {
562        let temp_dir = TempDir::new().unwrap();
563        let file_path = temp_dir.path().join("test.rs");
564        std::fs::write(&file_path, "fn main() {}").unwrap();
565
566        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
567        let result = tool
568            .execute(json!({
569                "path": "test.rs",
570                "old_string": "nonexistent",
571                "new_string": "replacement"
572            }))
573            .await;
574
575        assert!(result.is_err());
576    }
577
578    #[tokio::test]
579    async fn test_edit_file_multiple_without_replace_all() {
580        let temp_dir = TempDir::new().unwrap();
581        let file_path = temp_dir.path().join("test.rs");
582        std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
583
584        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
585        let result = tool
586            .execute(json!({
587                "path": "test.rs",
588                "old_string": "let x",
589                "new_string": "let y"
590            }))
591            .await;
592
593        // Should fail because there are multiple occurrences
594        assert!(result.is_err());
595    }
596
597    #[tokio::test]
598    async fn test_edit_file_replace_all() {
599        let temp_dir = TempDir::new().unwrap();
600        let file_path = temp_dir.path().join("test.rs");
601        std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
602
603        let tool = EditFileTool::new(temp_dir.path().to_path_buf());
604        let result = tool
605            .execute(json!({
606                "path": "test.rs",
607                "old_string": "let x",
608                "new_string": "let y",
609                "replace_all": true
610            }))
611            .await
612            .unwrap();
613
614        assert!(result["success"].as_bool().unwrap());
615        assert_eq!(result["replacements"], 2);
616
617        let new_content = std::fs::read_to_string(&file_path).unwrap();
618        assert!(!new_content.contains("let x"));
619        assert!(new_content.contains("let y"));
620    }
621
622    // --- EditFileLinesTool tests ---
623
624    #[tokio::test]
625    async fn test_edit_file_lines_middle() {
626        let temp_dir = TempDir::new().unwrap();
627        let file_path = temp_dir.path().join("test.rs");
628        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
629
630        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
631        let result = tool
632            .execute(json!({
633                "path": "test.rs",
634                "start_line": 2,
635                "end_line": 2,
636                "new_content": "replaced"
637            }))
638            .await
639            .unwrap();
640
641        assert!(result["success"].as_bool().unwrap());
642        assert_eq!(result["lines_replaced"], 1);
643        let content = std::fs::read_to_string(&file_path).unwrap();
644        assert_eq!(content, "line1\nreplaced\nline3\n");
645    }
646
647    #[tokio::test]
648    async fn test_edit_file_lines_first() {
649        let temp_dir = TempDir::new().unwrap();
650        let file_path = temp_dir.path().join("test.rs");
651        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
652
653        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
654        let result = tool
655            .execute(json!({
656                "path": "test.rs",
657                "start_line": 1,
658                "end_line": 1,
659                "new_content": "new_line1"
660            }))
661            .await
662            .unwrap();
663
664        assert!(result["success"].as_bool().unwrap());
665        let content = std::fs::read_to_string(&file_path).unwrap();
666        assert_eq!(content, "new_line1\nline2\nline3\n");
667    }
668
669    #[tokio::test]
670    async fn test_edit_file_lines_last() {
671        let temp_dir = TempDir::new().unwrap();
672        let file_path = temp_dir.path().join("test.rs");
673        std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
674
675        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
676        let result = tool
677            .execute(json!({
678                "path": "test.rs",
679                "start_line": 3,
680                "end_line": 3,
681                "new_content": "new_line3"
682            }))
683            .await
684            .unwrap();
685
686        assert!(result["success"].as_bool().unwrap());
687        let content = std::fs::read_to_string(&file_path).unwrap();
688        assert_eq!(content, "line1\nline2\nnew_line3\n");
689    }
690
691    #[tokio::test]
692    async fn test_edit_file_lines_multi_line_replacement() {
693        let temp_dir = TempDir::new().unwrap();
694        let file_path = temp_dir.path().join("test.rs");
695        std::fs::write(&file_path, "fn foo() {\n    old();\n}\n").unwrap();
696
697        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
698        let result = tool
699            .execute(json!({
700                "path": "test.rs",
701                "start_line": 1,
702                "end_line": 3,
703                "new_content": "fn foo() {\n    new_a();\n    new_b();\n}"
704            }))
705            .await
706            .unwrap();
707
708        assert!(result["success"].as_bool().unwrap());
709        assert_eq!(result["lines_replaced"], 3);
710        assert_eq!(result["new_line_count"], 4);
711        let content = std::fs::read_to_string(&file_path).unwrap();
712        assert!(content.contains("new_a()"));
713        assert!(content.contains("new_b()"));
714        assert!(!content.contains("old()"));
715    }
716
717    #[tokio::test]
718    async fn test_edit_file_lines_delete() {
719        let temp_dir = TempDir::new().unwrap();
720        let file_path = temp_dir.path().join("test.rs");
721        std::fs::write(&file_path, "line1\ndelete_me\nline3\n").unwrap();
722
723        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
724        let result = tool
725            .execute(json!({
726                "path": "test.rs",
727                "start_line": 2,
728                "end_line": 2,
729                "new_content": ""
730            }))
731            .await
732            .unwrap();
733
734        assert!(result["success"].as_bool().unwrap());
735        let content = std::fs::read_to_string(&file_path).unwrap();
736        assert_eq!(content, "line1\nline3\n");
737    }
738
739    #[tokio::test]
740    async fn test_edit_file_lines_out_of_bounds() {
741        let temp_dir = TempDir::new().unwrap();
742        let file_path = temp_dir.path().join("test.rs");
743        std::fs::write(&file_path, "line1\nline2\n").unwrap();
744
745        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
746        let result = tool
747            .execute(json!({
748                "path": "test.rs",
749                "start_line": 5,
750                "end_line": 5,
751                "new_content": "x"
752            }))
753            .await;
754
755        assert!(result.is_err());
756    }
757
758    #[tokio::test]
759    async fn test_edit_file_lines_end_before_start() {
760        let temp_dir = TempDir::new().unwrap();
761        let file_path = temp_dir.path().join("test.rs");
762        std::fs::write(&file_path, "line1\nline2\n").unwrap();
763
764        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
765        let result = tool
766            .execute(json!({
767                "path": "test.rs",
768                "start_line": 2,
769                "end_line": 1,
770                "new_content": "x"
771            }))
772            .await;
773
774        assert!(result.is_err());
775    }
776
777    #[tokio::test]
778    async fn test_edit_file_lines_preserves_no_trailing_newline() {
779        let temp_dir = TempDir::new().unwrap();
780        let file_path = temp_dir.path().join("test.rs");
781        // File without trailing newline
782        std::fs::write(&file_path, "line1\nline2").unwrap();
783
784        let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
785        tool.execute(json!({
786            "path": "test.rs",
787            "start_line": 1,
788            "end_line": 1,
789            "new_content": "replaced"
790        }))
791        .await
792        .unwrap();
793
794        let content = std::fs::read_to_string(&file_path).unwrap();
795        assert_eq!(content, "replaced\nline2");
796    }
797}