1use super::file::{normalize_path, validate_file_write};
4use super::Tool;
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 mutating(&self) -> bool {
40 true }
42
43 fn parameters_schema(&self) -> Value {
44 json!({
45 "type": "object",
46 "properties": {
47 "path": {
48 "type": "string",
49 "description": "Path to the file to edit"
50 },
51 "old_string": {
52 "type": "string",
53 "description": "The exact string to find and replace"
54 },
55 "new_string": {
56 "type": "string",
57 "description": "The string to replace it with"
58 },
59 "replace_all": {
60 "type": "boolean",
61 "description": "Replace all occurrences (default: false)"
62 }
63 },
64 "required": ["path", "old_string", "new_string"]
65 })
66 }
67
68 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
69 use thulp_core::{Parameter, ParameterType};
70 thulp_core::ToolDefinition::builder("edit_file")
71 .description(self.description())
72 .parameter(
73 Parameter::builder("path")
74 .param_type(ParameterType::String)
75 .required(true)
76 .description("Path to the file to edit")
77 .build(),
78 )
79 .parameter(
80 Parameter::builder("old_string")
81 .param_type(ParameterType::String)
82 .required(true)
83 .description("The exact string to find and replace")
84 .build(),
85 )
86 .parameter(
87 Parameter::builder("new_string")
88 .param_type(ParameterType::String)
89 .required(true)
90 .description("The string to replace it with")
91 .build(),
92 )
93 .parameter(
94 Parameter::builder("replace_all")
95 .param_type(ParameterType::Boolean)
96 .required(false)
97 .description("Replace all occurrences (default: false)")
98 .build(),
99 )
100 .build()
101 }
102
103 async fn execute(&self, args: Value) -> crate::Result<Value> {
104 let path = args["path"]
105 .as_str()
106 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
107
108 let old_string = args["old_string"]
109 .as_str()
110 .ok_or_else(|| crate::PawanError::Tool("old_string is required".into()))?;
111
112 let new_string_val = &args["new_string"];
113 let new_string = if let Some(arr) = new_string_val.as_array() {
114 arr.iter()
115 .filter_map(|v| v.as_str())
116 .collect::<Vec<_>>()
117 .join("\n")
118 } else {
119 new_string_val
120 .as_str()
121 .ok_or_else(|| crate::PawanError::Tool("new_string is required".into()))?
122 .to_string()
123 };
124 let new_string = &new_string;
125
126 let replace_all = args["replace_all"].as_bool().unwrap_or(false);
127
128 let full_path = self.resolve_path(path);
129 validate_file_write(&full_path).map_err(|r| {
130 crate::PawanError::Tool(format!("Edit blocked: {} — {}", full_path.display(), r))
131 })?;
132
133 if !full_path.exists() {
134 return Err(crate::PawanError::NotFound(format!(
135 "File not found: {}",
136 full_path.display()
137 )));
138 }
139
140 let content = tokio::fs::read_to_string(&full_path)
142 .await
143 .map_err(crate::PawanError::Io)?;
144
145 let occurrence_count = content.matches(old_string).count();
147
148 if occurrence_count == 0 {
149 return Err(crate::PawanError::Tool(
150 "old_string not found in file. Make sure the string matches exactly including whitespace.".to_string()
151 ));
152 }
153
154 if occurrence_count > 1 && !replace_all {
155 return Err(crate::PawanError::Tool(format!(
156 "old_string found {} times. Use replace_all: true to replace all, \
157 or provide more context to make the match unique.",
158 occurrence_count
159 )));
160 }
161
162 let new_content = if replace_all {
164 content.replace(old_string, new_string)
165 } else {
166 content.replacen(old_string, new_string, 1)
167 };
168
169 tokio::fs::write(&full_path, &new_content)
171 .await
172 .map_err(crate::PawanError::Io)?;
173
174 let diff = generate_diff(&content, &new_content, path);
176
177 Ok(json!({
178 "success": true,
179 "path": full_path.display().to_string(),
180 "replacements": if replace_all { occurrence_count } else { 1 },
181 "diff": diff
182 }))
183 }
184}
185
186pub struct EditFileLinesTool {
188 workspace_root: PathBuf,
189}
190
191impl EditFileLinesTool {
192 pub fn new(workspace_root: PathBuf) -> Self {
193 Self { workspace_root }
194 }
195
196 fn resolve_path(&self, path: &str) -> PathBuf {
197 normalize_path(&self.workspace_root, path)
198 }
199}
200
201#[async_trait]
202impl Tool for EditFileLinesTool {
203 fn name(&self) -> &str {
204 "edit_file_lines"
205 }
206
207 fn description(&self) -> &str {
208 "PREFERRED edit tool. Replace lines in a file. Two modes:\n\
209 Mode 1 (line numbers): pass start_line + end_line (1-based, inclusive).\n\
210 Mode 2 (anchor — MORE RELIABLE): pass anchor_text + anchor_count instead of line numbers. \
211 The tool finds the line containing anchor_text, then replaces anchor_count lines starting from that line.\n\
212 Always prefer Mode 2 (anchor) to avoid line-number miscounting.\n\
213 Set new_content to \"\" to delete lines."
214 }
215
216 fn parameters_schema(&self) -> Value {
217 json!({
218 "type": "object",
219 "properties": {
220 "path": {
221 "type": "string",
222 "description": "Path to the file to edit"
223 },
224 "start_line": {
225 "type": "integer",
226 "description": "First line to replace (1-based, inclusive). Optional if anchor_text is provided."
227 },
228 "end_line": {
229 "type": "integer",
230 "description": "Last line to replace (1-based, inclusive). Optional if anchor_text is provided."
231 },
232 "anchor_text": {
233 "type": "string",
234 "description": "PREFERRED: unique text that appears on the first line to replace. The tool finds this line automatically — no line-number math needed."
235 },
236 "anchor_count": {
237 "type": "integer",
238 "description": "Number of lines to replace starting from the anchor line (default: 1). Only used with anchor_text."
239 },
240 "new_content": {
241 "type": "string",
242 "description": "Replacement text for the specified lines. Empty string to delete lines."
243 }
244 },
245 "required": ["path", "new_content"]
246 })
247 }
248
249 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
250 use thulp_core::{Parameter, ParameterType};
251 thulp_core::ToolDefinition::builder("edit_file_lines")
252 .description(self.description())
253 .parameter(Parameter::builder("path").param_type(ParameterType::String).required(true)
254 .description("Path to the file to edit").build())
255 .parameter(Parameter::builder("start_line").param_type(ParameterType::Integer).required(false)
256 .description("First line to replace (1-based, inclusive). Optional if anchor_text is provided.").build())
257 .parameter(Parameter::builder("end_line").param_type(ParameterType::Integer).required(false)
258 .description("Last line to replace (1-based, inclusive). Optional if anchor_text is provided.").build())
259 .parameter(Parameter::builder("anchor_text").param_type(ParameterType::String).required(false)
260 .description("PREFERRED: unique text on the first line to replace. No line-number math needed.").build())
261 .parameter(Parameter::builder("anchor_count").param_type(ParameterType::Integer).required(false)
262 .description("Number of lines to replace starting from anchor line (default: 1).").build())
263 .parameter(Parameter::builder("new_content").param_type(ParameterType::String).required(true)
264 .description("Replacement text for the specified lines. Empty string to delete lines.").build())
265 .build()
266 }
267
268 async fn execute(&self, args: Value) -> crate::Result<Value> {
269 let path = args["path"]
270 .as_str()
271 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
272
273 let full_path = self.resolve_path(path);
274 validate_file_write(&full_path).map_err(|r| {
275 crate::PawanError::Tool(format!("Edit blocked: {} — {}", full_path.display(), r))
276 })?;
277 if !full_path.exists() {
278 return Err(crate::PawanError::NotFound(format!(
279 "File not found: {}",
280 full_path.display()
281 )));
282 }
283
284 let content = tokio::fs::read_to_string(&full_path)
285 .await
286 .map_err(crate::PawanError::Io)?;
287
288 let had_trailing_newline = content.ends_with('\n');
289 let lines: Vec<&str> = content.lines().collect();
290 let total_lines = lines.len();
291
292 let (start_line, end_line) = if let Some(anchor) = args["anchor_text"].as_str() {
294 let anchor_count = args["anchor_count"].as_u64().unwrap_or(1) as usize;
297 let anchor_normalized: String = anchor.split_whitespace().collect::<Vec<_>>().join(" ");
298 let found = lines.iter().position(|l| {
299 if l.contains(anchor) {
301 return true;
302 }
303 let line_normalized: String = l.split_whitespace().collect::<Vec<_>>().join(" ");
305 line_normalized.contains(&anchor_normalized)
306 });
307 match found {
308 Some(idx) => {
309 let start = idx + 1; let end = (start + anchor_count - 1).min(total_lines);
311 (start, end)
312 }
313 None => {
314 let anchor_lower = anchor_normalized.to_lowercase();
316 let found_ci = lines.iter().position(|l| {
317 let norm: String = l
318 .split_whitespace()
319 .collect::<Vec<_>>()
320 .join(" ")
321 .to_lowercase();
322 norm.contains(&anchor_lower)
323 });
324 match found_ci {
325 Some(idx) => {
326 let start = idx + 1;
327 let end = (start + anchor_count - 1).min(total_lines);
328 (start, end)
329 }
330 None => {
331 return Err(crate::PawanError::Tool(format!(
332 "anchor_text {:?} not found in file ({} lines). Try a shorter or different anchor string.",
333 anchor, total_lines
334 )));
335 }
336 }
337 }
338 }
339 } else {
340 let start = args["start_line"].as_u64().ok_or_else(|| {
342 crate::PawanError::Tool(
343 "Either anchor_text or start_line+end_line is required".into(),
344 )
345 })? as usize;
346 let end = args["end_line"]
347 .as_u64()
348 .ok_or_else(|| crate::PawanError::Tool("end_line is required".into()))?
349 as usize;
350 (start, end)
351 };
352
353 let new_content_val = &args["new_content"];
354 let new_content = if let Some(arr) = new_content_val.as_array() {
355 arr.iter()
356 .filter_map(|v| v.as_str())
357 .collect::<Vec<_>>()
358 .join("\n")
359 } else {
360 new_content_val
361 .as_str()
362 .ok_or_else(|| crate::PawanError::Tool("new_content is required".into()))?
363 .to_string()
364 };
365 let new_content = &new_content;
366
367 if start_line == 0 {
368 return Err(crate::PawanError::Tool(
369 "start_line must be >= 1 (lines are 1-based)".into(),
370 ));
371 }
372
373 if end_line < start_line {
374 return Err(crate::PawanError::Tool(format!(
375 "end_line ({end_line}) must be >= start_line ({start_line})"
376 )));
377 }
378
379 if start_line > total_lines {
380 return Err(crate::PawanError::Tool(format!(
381 "start_line ({start_line}) exceeds file length ({total_lines} lines). \
382 TIP: use anchor_text instead of line numbers to avoid this error."
383 )));
384 }
385
386 if end_line > total_lines {
387 return Err(crate::PawanError::Tool(format!(
388 "end_line ({end_line}) exceeds file length ({total_lines} lines). \
389 TIP: use anchor_text instead of line numbers to avoid this error."
390 )));
391 }
392
393 let new_lines: Vec<&str> = new_content.lines().collect();
394 let lines_replaced = end_line - start_line + 1;
395
396 let replaced_lines: Vec<String> = lines[start_line - 1..end_line]
398 .iter()
399 .enumerate()
400 .map(|(i, l)| format!("{:>4} | {}", start_line + i, l))
401 .collect();
402 let replaced_preview = replaced_lines.join("\n");
403
404 let before = &lines[..start_line - 1];
405 let after = &lines[end_line..];
406
407 let mut result_lines: Vec<&str> =
408 Vec::with_capacity(before.len() + new_lines.len() + after.len());
409 result_lines.extend_from_slice(before);
410 result_lines.extend_from_slice(&new_lines);
411 result_lines.extend_from_slice(after);
412
413 let mut new_content_str = result_lines.join("\n");
414 if had_trailing_newline && !new_content_str.is_empty() {
415 new_content_str.push('\n');
416 }
417
418 tokio::fs::write(&full_path, &new_content_str)
419 .await
420 .map_err(crate::PawanError::Io)?;
421
422 let diff = generate_diff(&content, &new_content_str, path);
423
424 Ok(json!({
425 "success": true,
426 "path": full_path.display().to_string(),
427 "lines_replaced": lines_replaced,
428 "new_line_count": new_lines.len(),
429 "replaced_content": replaced_preview,
430 "diff": diff
431 }))
432 }
433}
434
435fn generate_diff(old: &str, new: &str, filename: &str) -> String {
437 use similar::{ChangeTag, TextDiff};
438
439 let diff = TextDiff::from_lines(old, new);
440 let mut result = String::new();
441
442 result.push_str(&format!("--- a/{}\n", filename));
443 result.push_str(&format!("+++ b/{}\n", filename));
444
445 for (idx, group) in diff.grouped_ops(3).iter().enumerate() {
446 if idx > 0 {
447 result.push_str("...\n");
448 }
449
450 for op in group {
451 for change in diff.iter_changes(op) {
452 let sign = match change.tag() {
453 ChangeTag::Delete => "-",
454 ChangeTag::Insert => "+",
455 ChangeTag::Equal => " ",
456 };
457 result.push_str(&format!("{}{}", sign, change));
458 }
459 }
460 }
461
462 result
463}
464
465pub struct InsertAfterTool {
468 workspace_root: PathBuf,
470}
471
472impl InsertAfterTool {
473 pub fn new(workspace_root: PathBuf) -> Self {
474 Self { workspace_root }
475 }
476
477 fn resolve_path(&self, path: &str) -> PathBuf {
478 normalize_path(&self.workspace_root, path)
479 }
480}
481
482#[async_trait]
483impl Tool for InsertAfterTool {
484 fn name(&self) -> &str {
485 "insert_after"
486 }
487
488 fn description(&self) -> &str {
489 "Insert text after a line matching a pattern. Finds the FIRST line containing \
490 the anchor text. If that line opens a block (ends with '{'), inserts AFTER the \
491 closing '}' of that block — safe for functions, structs, impls. Otherwise inserts \
492 on the next line. Does not replace anything. Use for adding new code."
493 }
494
495 fn parameters_schema(&self) -> Value {
496 json!({
497 "type": "object",
498 "properties": {
499 "path": { "type": "string", "description": "Path to the file" },
500 "anchor_text": { "type": "string", "description": "Text to find — insertion happens AFTER this line" },
501 "content": { "type": "string", "description": "Text to insert after the anchor line" }
502 },
503 "required": ["path", "anchor_text", "content"]
504 })
505 }
506
507 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
508 use thulp_core::{Parameter, ParameterType};
509 thulp_core::ToolDefinition::builder("insert_after")
510 .description(self.description())
511 .parameter(
512 Parameter::builder("path")
513 .param_type(ParameterType::String)
514 .required(true)
515 .description("Path to the file")
516 .build(),
517 )
518 .parameter(
519 Parameter::builder("anchor_text")
520 .param_type(ParameterType::String)
521 .required(true)
522 .description("Text to find — insertion happens AFTER this line")
523 .build(),
524 )
525 .parameter(
526 Parameter::builder("content")
527 .param_type(ParameterType::String)
528 .required(true)
529 .description("Text to insert after the anchor line")
530 .build(),
531 )
532 .build()
533 }
534
535 async fn execute(&self, args: Value) -> crate::Result<Value> {
536 let path = args["path"]
537 .as_str()
538 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
539 let anchor = args["anchor_text"]
540 .as_str()
541 .ok_or_else(|| crate::PawanError::Tool("anchor_text is required".into()))?;
542 let insert_content = args["content"]
543 .as_str()
544 .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
545
546 let full_path = self.resolve_path(path);
547 validate_file_write(&full_path).map_err(|r| {
548 crate::PawanError::Tool(format!("Edit blocked: {} — {}", full_path.display(), r))
549 })?;
550 if !full_path.exists() {
551 return Err(crate::PawanError::NotFound(format!(
552 "File not found: {}",
553 full_path.display()
554 )));
555 }
556
557 let content = tokio::fs::read_to_string(&full_path)
558 .await
559 .map_err(crate::PawanError::Io)?;
560 let had_trailing_newline = content.ends_with('\n');
561 let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
562
563 let anchor_normalized: String = anchor.split_whitespace().collect::<Vec<_>>().join(" ");
565 let found = lines.iter().position(|l| {
566 if l.contains(anchor) {
567 return true;
568 }
569 let norm: String = l.split_whitespace().collect::<Vec<_>>().join(" ");
570 norm.contains(&anchor_normalized)
571 || norm
572 .to_lowercase()
573 .contains(&anchor_normalized.to_lowercase())
574 });
575 match found {
576 Some(idx) => {
577 let insert_lines: Vec<String> =
578 insert_content.lines().map(|l| l.to_string()).collect();
579 let insert_count = insert_lines.len();
580
581 let anchor_line = &lines[idx];
583 let insert_at = if anchor_line.trim_end().ends_with('{') {
584 let mut depth = 0i32;
586 let mut close_idx = idx;
587 for (i, line) in lines.iter().enumerate().skip(idx) {
588 for ch in line.chars() {
589 if ch == '{' {
590 depth += 1;
591 }
592 if ch == '}' {
593 depth -= 1;
594 }
595 }
596 if depth == 0 {
597 close_idx = i;
598 break;
599 }
600 }
601 close_idx + 1
602 } else {
603 idx + 1
604 };
605 for (i, line) in insert_lines.into_iter().enumerate() {
606 lines.insert(insert_at + i, line);
607 }
608 let mut new_content = lines.join("\n");
609 if had_trailing_newline {
610 new_content.push('\n');
611 }
612 let diff = generate_diff(&content, &new_content, path);
613 tokio::fs::write(&full_path, &new_content)
614 .await
615 .map_err(crate::PawanError::Io)?;
616 let block_skipped = insert_at != idx + 1;
617 Ok(json!({
618 "success": true,
619 "path": full_path.display().to_string(),
620 "anchor_line": idx + 1,
621 "inserted_after_line": insert_at,
622 "block_skipped": block_skipped,
623 "block_skip_note": if block_skipped { format!("Anchor line {} opens a block — inserted after closing '}}' at line {}", idx + 1, insert_at) } else { String::new() },
624 "lines_inserted": insert_count,
625 "anchor_matched": lines.get(idx).unwrap_or(&String::new()).trim(),
626 "diff": diff
627 }))
628 }
629 None => Err(crate::PawanError::Tool(format!(
630 "anchor_text {:?} not found in file",
631 anchor
632 ))),
633 }
634 }
635}
636
637pub struct AppendFileTool {
639 workspace_root: PathBuf,
640}
641
642impl AppendFileTool {
643 pub fn new(workspace_root: PathBuf) -> Self {
644 Self { workspace_root }
645 }
646
647 fn resolve_path(&self, path: &str) -> PathBuf {
648 normalize_path(&self.workspace_root, path)
649 }
650}
651
652#[async_trait]
653impl Tool for AppendFileTool {
654 fn name(&self) -> &str {
655 "append_file"
656 }
657
658 fn description(&self) -> &str {
659 "Append content to the end of a file. Creates the file if it doesn't exist. \
660 Use for adding new functions, tests, or sections without touching existing content. \
661 Safer than write_file for large additions."
662 }
663
664 fn parameters_schema(&self) -> Value {
665 json!({
666 "type": "object",
667 "properties": {
668 "path": { "type": "string", "description": "Path to the file" },
669 "content": { "type": "string", "description": "Content to append" }
670 },
671 "required": ["path", "content"]
672 })
673 }
674
675 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
676 use thulp_core::{Parameter, ParameterType};
677 thulp_core::ToolDefinition::builder("append_file")
678 .description(self.description())
679 .parameter(
680 Parameter::builder("path")
681 .param_type(ParameterType::String)
682 .required(true)
683 .description("Path to the file")
684 .build(),
685 )
686 .parameter(
687 Parameter::builder("content")
688 .param_type(ParameterType::String)
689 .required(true)
690 .description("Content to append")
691 .build(),
692 )
693 .build()
694 }
695
696 async fn execute(&self, args: Value) -> crate::Result<Value> {
697 let path = args["path"]
698 .as_str()
699 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
700 let append_content = args["content"]
701 .as_str()
702 .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
703
704 let full_path = self.resolve_path(path);
705 if let Some(parent) = full_path.parent() {
706 tokio::fs::create_dir_all(parent)
707 .await
708 .map_err(crate::PawanError::Io)?;
709 }
710
711 let existing = if full_path.exists() {
712 tokio::fs::read_to_string(&full_path)
713 .await
714 .map_err(crate::PawanError::Io)?
715 } else {
716 String::new()
717 };
718
719 let separator = if existing.is_empty() || existing.ends_with('\n') {
720 ""
721 } else {
722 "\n"
723 };
724 let new_content = format!("{}{}{}\n", existing, separator, append_content);
725 let appended_lines = append_content.lines().count();
726
727 tokio::fs::write(&full_path, &new_content)
728 .await
729 .map_err(crate::PawanError::Io)?;
730
731 Ok(json!({
732 "success": true,
733 "path": full_path.display().to_string(),
734 "lines_appended": appended_lines,
735 "total_lines": new_content.lines().count()
736 }))
737 }
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743 use tempfile::TempDir;
744
745 #[tokio::test]
746 async fn test_edit_file_single_replacement() {
747 let temp_dir = TempDir::new().unwrap();
748 let file_path = temp_dir.path().join("test.rs");
749 std::fs::write(&file_path, "fn main() {\n println!(\"Hello\");\n}").unwrap();
750
751 let tool = EditFileTool::new(temp_dir.path().to_path_buf());
752 let result = tool
753 .execute(json!({
754 "path": "test.rs",
755 "old_string": "println!(\"Hello\")",
756 "new_string": "println!(\"Hello, World!\")"
757 }))
758 .await
759 .unwrap();
760
761 assert!(result["success"].as_bool().unwrap());
762 assert_eq!(result["replacements"], 1);
763
764 let new_content = std::fs::read_to_string(&file_path).unwrap();
765 assert!(new_content.contains("Hello, World!"));
766 }
767
768 #[tokio::test]
769 async fn test_edit_file_not_found() {
770 let temp_dir = TempDir::new().unwrap();
771 let file_path = temp_dir.path().join("test.rs");
772 std::fs::write(&file_path, "fn main() {}").unwrap();
773
774 let tool = EditFileTool::new(temp_dir.path().to_path_buf());
775 let result = tool
776 .execute(json!({
777 "path": "test.rs",
778 "old_string": "nonexistent",
779 "new_string": "replacement"
780 }))
781 .await;
782
783 assert!(result.is_err());
784 }
785
786 #[tokio::test]
787 async fn test_edit_file_multiple_without_replace_all() {
788 let temp_dir = TempDir::new().unwrap();
789 let file_path = temp_dir.path().join("test.rs");
790 std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
791
792 let tool = EditFileTool::new(temp_dir.path().to_path_buf());
793 let result = tool
794 .execute(json!({
795 "path": "test.rs",
796 "old_string": "let x",
797 "new_string": "let y"
798 }))
799 .await;
800
801 assert!(result.is_err());
803 }
804
805 #[tokio::test]
806 async fn test_edit_file_replace_all() {
807 let temp_dir = TempDir::new().unwrap();
808 let file_path = temp_dir.path().join("test.rs");
809 std::fs::write(&file_path, "let x = 1;\nlet x = 2;").unwrap();
810
811 let tool = EditFileTool::new(temp_dir.path().to_path_buf());
812 let result = tool
813 .execute(json!({
814 "path": "test.rs",
815 "old_string": "let x",
816 "new_string": "let y",
817 "replace_all": true
818 }))
819 .await
820 .unwrap();
821
822 assert!(result["success"].as_bool().unwrap());
823 assert_eq!(result["replacements"], 2);
824
825 let new_content = std::fs::read_to_string(&file_path).unwrap();
826 assert!(!new_content.contains("let x"));
827 assert!(new_content.contains("let y"));
828 }
829
830 #[tokio::test]
833 async fn test_edit_file_lines_middle() {
834 let temp_dir = TempDir::new().unwrap();
835 let file_path = temp_dir.path().join("test.rs");
836 std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
837
838 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
839 let result = tool
840 .execute(json!({
841 "path": "test.rs",
842 "start_line": 2,
843 "end_line": 2,
844 "new_content": "replaced"
845 }))
846 .await
847 .unwrap();
848
849 assert!(result["success"].as_bool().unwrap());
850 assert_eq!(result["lines_replaced"], 1);
851 let content = std::fs::read_to_string(&file_path).unwrap();
852 assert_eq!(content, "line1\nreplaced\nline3\n");
853 }
854
855 #[tokio::test]
856 async fn test_edit_file_lines_first() {
857 let temp_dir = TempDir::new().unwrap();
858 let file_path = temp_dir.path().join("test.rs");
859 std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
860
861 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
862 let result = tool
863 .execute(json!({
864 "path": "test.rs",
865 "start_line": 1,
866 "end_line": 1,
867 "new_content": "new_line1"
868 }))
869 .await
870 .unwrap();
871
872 assert!(result["success"].as_bool().unwrap());
873 let content = std::fs::read_to_string(&file_path).unwrap();
874 assert_eq!(content, "new_line1\nline2\nline3\n");
875 }
876
877 #[tokio::test]
878 async fn test_edit_file_lines_last() {
879 let temp_dir = TempDir::new().unwrap();
880 let file_path = temp_dir.path().join("test.rs");
881 std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
882
883 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
884 let result = tool
885 .execute(json!({
886 "path": "test.rs",
887 "start_line": 3,
888 "end_line": 3,
889 "new_content": "new_line3"
890 }))
891 .await
892 .unwrap();
893
894 assert!(result["success"].as_bool().unwrap());
895 let content = std::fs::read_to_string(&file_path).unwrap();
896 assert_eq!(content, "line1\nline2\nnew_line3\n");
897 }
898
899 #[tokio::test]
900 async fn test_edit_file_lines_multi_line_replacement() {
901 let temp_dir = TempDir::new().unwrap();
902 let file_path = temp_dir.path().join("test.rs");
903 std::fs::write(&file_path, "fn foo() {\n old();\n}\n").unwrap();
904
905 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
906 let result = tool
907 .execute(json!({
908 "path": "test.rs",
909 "start_line": 1,
910 "end_line": 3,
911 "new_content": "fn foo() {\n new_a();\n new_b();\n}"
912 }))
913 .await
914 .unwrap();
915
916 assert!(result["success"].as_bool().unwrap());
917 assert_eq!(result["lines_replaced"], 3);
918 assert_eq!(result["new_line_count"], 4);
919 let content = std::fs::read_to_string(&file_path).unwrap();
920 assert!(content.contains("new_a()"));
921 assert!(content.contains("new_b()"));
922 assert!(!content.contains("old()"));
923 }
924
925 #[tokio::test]
926 async fn test_edit_file_lines_delete() {
927 let temp_dir = TempDir::new().unwrap();
928 let file_path = temp_dir.path().join("test.rs");
929 std::fs::write(&file_path, "line1\ndelete_me\nline3\n").unwrap();
930
931 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
932 let result = tool
933 .execute(json!({
934 "path": "test.rs",
935 "start_line": 2,
936 "end_line": 2,
937 "new_content": ""
938 }))
939 .await
940 .unwrap();
941
942 assert!(result["success"].as_bool().unwrap());
943 let content = std::fs::read_to_string(&file_path).unwrap();
944 assert_eq!(content, "line1\nline3\n");
945 }
946
947 #[tokio::test]
948 async fn test_edit_file_lines_out_of_bounds() {
949 let temp_dir = TempDir::new().unwrap();
950 let file_path = temp_dir.path().join("test.rs");
951 std::fs::write(&file_path, "line1\nline2\n").unwrap();
952
953 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
954 let result = tool
955 .execute(json!({
956 "path": "test.rs",
957 "start_line": 5,
958 "end_line": 5,
959 "new_content": "x"
960 }))
961 .await;
962
963 assert!(result.is_err());
964 }
965
966 #[tokio::test]
967 async fn test_edit_file_lines_end_before_start() {
968 let temp_dir = TempDir::new().unwrap();
969 let file_path = temp_dir.path().join("test.rs");
970 std::fs::write(&file_path, "line1\nline2\n").unwrap();
971
972 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
973 let result = tool
974 .execute(json!({
975 "path": "test.rs",
976 "start_line": 2,
977 "end_line": 1,
978 "new_content": "x"
979 }))
980 .await;
981
982 assert!(result.is_err());
983 }
984
985 #[tokio::test]
986 async fn test_edit_file_lines_preserves_no_trailing_newline() {
987 let temp_dir = TempDir::new().unwrap();
988 let file_path = temp_dir.path().join("test.rs");
989 std::fs::write(&file_path, "line1\nline2").unwrap();
991
992 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
993 tool.execute(json!({
994 "path": "test.rs",
995 "start_line": 1,
996 "end_line": 1,
997 "new_content": "replaced"
998 }))
999 .await
1000 .unwrap();
1001
1002 let content = std::fs::read_to_string(&file_path).unwrap();
1003 assert_eq!(content, "replaced\nline2");
1004 }
1005
1006 #[tokio::test]
1009 async fn test_edit_file_lines_anchor_mode_finds_and_replaces() {
1010 let temp_dir = TempDir::new().unwrap();
1011 let file_path = temp_dir.path().join("test.rs");
1012 std::fs::write(&file_path, "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n").unwrap();
1013
1014 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
1015 let result = tool
1016 .execute(json!({
1017 "path": "test.rs",
1018 "anchor_text": "fn beta",
1019 "anchor_count": 1,
1020 "new_content": "fn beta_renamed() {}"
1021 }))
1022 .await
1023 .unwrap();
1024
1025 assert!(result["success"].as_bool().unwrap());
1026 assert_eq!(result["lines_replaced"], 1);
1027 let content = std::fs::read_to_string(&file_path).unwrap();
1028 assert!(content.contains("fn beta_renamed() {}"));
1029 assert!(content.contains("fn alpha() {}"));
1030 assert!(content.contains("fn gamma() {}"));
1031 assert!(!content.contains("fn beta() {}"));
1032 }
1033
1034 #[tokio::test]
1035 async fn test_edit_file_lines_anchor_not_found_errors() {
1036 let temp_dir = TempDir::new().unwrap();
1037 let file_path = temp_dir.path().join("test.rs");
1038 std::fs::write(&file_path, "fn alpha() {}\n").unwrap();
1039
1040 let tool = EditFileLinesTool::new(temp_dir.path().to_path_buf());
1041 let result = tool
1042 .execute(json!({
1043 "path": "test.rs",
1044 "anchor_text": "nonexistent_function_xyz",
1045 "new_content": "replacement"
1046 }))
1047 .await;
1048
1049 assert!(result.is_err());
1050 let err_msg = format!("{}", result.unwrap_err());
1051 assert!(err_msg.contains("not found"), "got: {}", err_msg);
1052 }
1053
1054 #[tokio::test]
1057 async fn test_insert_after_simple_line() {
1058 let temp_dir = TempDir::new().unwrap();
1059 let file_path = temp_dir.path().join("test.rs");
1060 std::fs::write(&file_path, "// header\nfn existing() {}\n").unwrap();
1061
1062 let tool = InsertAfterTool::new(temp_dir.path().to_path_buf());
1063 let result = tool
1064 .execute(json!({
1065 "path": "test.rs",
1066 "anchor_text": "// header",
1067 "content": "// new comment"
1068 }))
1069 .await
1070 .unwrap();
1071
1072 assert!(result["success"].as_bool().unwrap());
1073 assert_eq!(result["lines_inserted"], 1);
1074 assert_eq!(result["block_skipped"], false);
1075 let content = std::fs::read_to_string(&file_path).unwrap();
1076 assert_eq!(content, "// header\n// new comment\nfn existing() {}\n");
1077 }
1078
1079 #[tokio::test]
1080 async fn test_insert_after_block_skip() {
1081 let temp_dir = TempDir::new().unwrap();
1083 let file_path = temp_dir.path().join("test.rs");
1084 std::fs::write(
1085 &file_path,
1086 "fn first() {\n println!(\"a\");\n}\nfn third() {}\n",
1087 )
1088 .unwrap();
1089
1090 let tool = InsertAfterTool::new(temp_dir.path().to_path_buf());
1091 let result = tool
1092 .execute(json!({
1093 "path": "test.rs",
1094 "anchor_text": "fn first()",
1095 "content": "fn second() {}"
1096 }))
1097 .await
1098 .unwrap();
1099
1100 assert!(result["success"].as_bool().unwrap());
1101 assert_eq!(result["block_skipped"], true);
1102 let content = std::fs::read_to_string(&file_path).unwrap();
1103 let first_close = content
1105 .find("}\nfn second()")
1106 .expect("second should be inserted after first's '}'");
1107 assert!(first_close > content.find("println!").unwrap());
1108 assert!(content.find("fn second()").unwrap() < content.find("fn third()").unwrap());
1110 }
1111
1112 #[tokio::test]
1113 async fn test_insert_after_anchor_not_found() {
1114 let temp_dir = TempDir::new().unwrap();
1115 let file_path = temp_dir.path().join("test.rs");
1116 std::fs::write(&file_path, "fn alpha() {}\n").unwrap();
1117
1118 let tool = InsertAfterTool::new(temp_dir.path().to_path_buf());
1119 let result = tool
1120 .execute(json!({
1121 "path": "test.rs",
1122 "anchor_text": "completely_missing_marker",
1123 "content": "new"
1124 }))
1125 .await;
1126
1127 assert!(result.is_err());
1128 }
1129
1130 #[tokio::test]
1133 async fn test_append_file_creates_new_file() {
1134 let temp_dir = TempDir::new().unwrap();
1135 let tool = AppendFileTool::new(temp_dir.path().to_path_buf());
1136
1137 let result = tool
1138 .execute(json!({
1139 "path": "new_file.md",
1140 "content": "# Hello\n\nFirst line"
1141 }))
1142 .await
1143 .unwrap();
1144
1145 assert!(result["success"].as_bool().unwrap());
1146 assert_eq!(result["lines_appended"], 3);
1147 let created = temp_dir.path().join("new_file.md");
1148 assert!(created.exists());
1149 let content = std::fs::read_to_string(&created).unwrap();
1150 assert_eq!(content, "# Hello\n\nFirst line\n");
1151 }
1152
1153 #[tokio::test]
1154 async fn test_append_file_adds_to_existing() {
1155 let temp_dir = TempDir::new().unwrap();
1156 let file_path = temp_dir.path().join("log.txt");
1157 std::fs::write(&file_path, "entry one\n").unwrap();
1158
1159 let tool = AppendFileTool::new(temp_dir.path().to_path_buf());
1160 tool.execute(json!({
1161 "path": "log.txt",
1162 "content": "entry two"
1163 }))
1164 .await
1165 .unwrap();
1166
1167 let content = std::fs::read_to_string(&file_path).unwrap();
1168 assert_eq!(content, "entry one\nentry two\n");
1169 }
1170}