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;
221 let found = lines.iter().position(|l| l.contains(anchor));
222 match found {
223 Some(idx) => {
224 let start = idx + 1; 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 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 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
320fn 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
350pub struct InsertAfterTool {
353workspace_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 let anchor_line = &lines[idx];
417 let insert_at = if anchor_line.trim_end().ends_with('{') {
418 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
462pub 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 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 #[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 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}