Skip to main content

pawan/tools/
edit.rs

1//! Edit tool for precise string replacement with write safety
2
3use 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
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(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        // Read current content
113        let content = tokio::fs::read_to_string(&full_path)
114            .await
115            .map_err(crate::PawanError::Io)?;
116
117        // Count occurrences
118        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        // Perform replacement
135        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        // Write back
142        tokio::fs::write(&full_path, &new_content)
143            .await
144            .map_err(crate::PawanError::Io)?;
145
146        // Generate a diff preview
147        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
158/// Tool for editing files by replacing a range of lines
159pub 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        // Resolve start_line and end_line — either from explicit numbers or anchor
262        let (start_line, end_line) = if let Some(anchor) = args["anchor_text"].as_str() {
263            // Anchor mode: find line containing anchor_text
264            // Fuzzy matching: normalize whitespace for comparison
265            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                // Try exact match first
269                if l.contains(anchor) { return true; }
270                // Then try whitespace-normalized match
271                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; // convert to 1-based
277                    let end = (start + anchor_count - 1).min(total_lines);
278                    (start, end)
279                }
280                None => {
281                    // Try case-insensitive as last resort
282                    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            // Line number mode
304            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        // Context echo: capture what's being replaced (helps LLM verify correctness)
353        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
391/// Generate a simple diff between two strings
392fn 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
421/// Tool for inserting text after a line matching a pattern.
422/// Safer than edit_file_lines for additions — never replaces existing content.
423pub struct InsertAfterTool {
424/// Tool for inserting text after a line matching a pattern.
425    workspace_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        // Fuzzy anchor matching: exact → whitespace-normalized → case-insensitive
495        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                // Smart insertion: if anchor line opens a block ({), insert AFTER the block closes
507                let anchor_line = &lines[idx];
508                let insert_at = if anchor_line.trim_end().ends_with('{') {
509                    // Find matching closing brace
510                    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
553/// Tool for appending content to the end of a file
554pub 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        // Should fail because there are multiple occurrences
696        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    // --- EditFileLinesTool tests ---
725
726    #[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        // File without trailing newline
884        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    // --- EditFileLinesTool anchor mode tests ---
901
902    #[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    // --- InsertAfterTool tests (previously ZERO coverage) ---
953
954    #[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        // Anchor line ends with '{' — insert should jump past the closing '}'
980        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        // fn second() must appear AFTER the closing brace of fn first()
1002        let first_close = content.find("}\nfn second()").expect("second should be inserted after first's '}'");
1003        assert!(first_close > content.find("println!").unwrap());
1004        // And before fn third()
1005        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    // --- AppendFileTool tests (previously ZERO coverage) ---
1027
1028    #[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}