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