Skip to main content

pawan/tools/
edit.rs

1//! Edit tool for precise string replacement with write safety
2
3use 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
9/// Tool for editing files with precise string replacement
10pub 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 // Editing files mutates state
41    }
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        // Read current content
141        let content = tokio::fs::read_to_string(&full_path)
142            .await
143            .map_err(crate::PawanError::Io)?;
144
145        // Count occurrences
146        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        // Perform replacement
163        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        // Write back
170        tokio::fs::write(&full_path, &new_content)
171            .await
172            .map_err(crate::PawanError::Io)?;
173
174        // Generate a diff preview
175        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
186/// Tool for editing files by replacing a range of lines
187pub 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        // Resolve start_line and end_line — either from explicit numbers or anchor
293        let (start_line, end_line) = if let Some(anchor) = args["anchor_text"].as_str() {
294            // Anchor mode: find line containing anchor_text
295            // Fuzzy matching: normalize whitespace for comparison
296            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                // Try exact match first
300                if l.contains(anchor) {
301                    return true;
302                }
303                // Then try whitespace-normalized match
304                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; // convert to 1-based
310                    let end = (start + anchor_count - 1).min(total_lines);
311                    (start, end)
312                }
313                None => {
314                    // Try case-insensitive as last resort
315                    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            // Line number mode
341            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        // Context echo: capture what's being replaced (helps LLM verify correctness)
397        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
435/// Generate a simple diff between two strings
436fn 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
465/// Tool for inserting text after a line matching a pattern.
466/// Safer than edit_file_lines for additions — never replaces existing content.
467pub struct InsertAfterTool {
468    /// Tool for inserting text after a line matching a pattern.
469    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        // Fuzzy anchor matching: exact → whitespace-normalized → case-insensitive
564        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                // Smart insertion: if anchor line opens a block ({), insert AFTER the block closes
582                let anchor_line = &lines[idx];
583                let insert_at = if anchor_line.trim_end().ends_with('{') {
584                    // Find matching closing brace
585                    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
637/// Tool for appending content to the end of a file
638pub 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        // Should fail because there are multiple occurrences
802        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    // --- EditFileLinesTool tests ---
831
832    #[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        // File without trailing newline
990        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    // --- EditFileLinesTool anchor mode tests ---
1007
1008    #[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    // --- InsertAfterTool tests (previously ZERO coverage) ---
1055
1056    #[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        // Anchor line ends with '{' — insert should jump past the closing '}'
1082        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        // fn second() must appear AFTER the closing brace of fn first()
1104        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        // And before fn third()
1109        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    // --- AppendFileTool tests (previously ZERO coverage) ---
1131
1132    #[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}