1use super::Tool;
4use super::file::normalize_path;
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::PathBuf;
8
9pub 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 let content = tokio::fs::read_to_string(&full_path)
90 .await
91 .map_err(crate::PawanError::Io)?;
92
93 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 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 tokio::fs::write(&full_path, &new_content)
119 .await
120 .map_err(crate::PawanError::Io)?;
121
122 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
134pub 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 let (start_line, end_line) = if let Some(anchor) = args["anchor_text"].as_str() {
219 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 if l.contains(anchor) { return true; }
226 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; let end = (start + anchor_count - 1).min(total_lines);
234 (start, end)
235 }
236 None => {
237 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 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 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
343fn 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
373pub struct InsertAfterTool {
376workspace_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 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 let anchor_line = &lines[idx];
446 let insert_at = if anchor_line.trim_end().ends_with('{') {
447 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
491pub 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 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 #[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 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}