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