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