""" File editing tool with find-and-replace functionality. """ import difflib import aiofiles from pathlib import Path from src.domains.tools.base import BaseTool, ToolResult from src.shared.logging import logged, get_logger logger = get_logger(__name__) # Binary file extensions to skip BINARY_EXTENSIONS = { '.pyc', '.pyo', '.so', '.o', '.a', '.lib', '.dll', '.exe', '.bin', '.dat', '.db', '.sqlite', '.sqlite3', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.bmp', '.webp', '.pdf', '.doc', '.docx', '.xls', '.xlsx', '.zip', '.tar', '.gz', '.bz2', '.7z', '.rar', '.mp3', '.mp4', '.avi', '.mov', '.wav', '.woff', '.woff2', '.ttf', '.eot', } class EditFileTool(BaseTool): """ Edit files using find-and-replace. Safely modifies files by finding exact text matches and replacing them. Includes safety checks to prevent accidental edits. """ name = "edit_file" description = """Make targeted edits to a file using find-and-replace. Args: file_path: Absolute path to the file to edit old_string: The exact text to find and replace (must exist in file) new_string: The replacement text replace_all: If True, replace all occurrences. If False (default), old_string must be unique in the file (appear exactly once). Returns: Success message with diff preview showing changes, or error. IMPORTANT: - The old_string must exactly match text in the file (including whitespace/indentation) - By default, old_string must appear exactly once in the file (for safety) - Use replace_all=True only when you intentionally want to replace all occurrences - Always read the file first to verify exact content before editing - Cannot edit binary files Examples: - Fix a bug: old_string="return x + y", new_string="return x * y" - Rename function: old_string="def old_name(", new_string="def new_name(" - Add import: old_string="import os", new_string="import os\\nimport sys" """ def __init__( self, allowed_paths: list[str] | None = None, max_file_size: int = 1_000_000, # 1MB ): """ Initialize EditFileTool. Args: allowed_paths: List of allowed directory prefixes (empty = no restrictions) max_file_size: Maximum file size to edit in bytes """ self.allowed_paths = allowed_paths or [] self.max_file_size = max_file_size def _is_binary_file(self, path: Path) -> bool: """Check if file is likely binary based on extension.""" return path.suffix.lower() in BINARY_EXTENSIONS def _generate_diff( self, original: str, modified: str, file_path: str ) -> str: """Generate a unified diff between original and modified content.""" original_lines = original.splitlines(keepends=True) modified_lines = modified.splitlines(keepends=True) diff = difflib.unified_diff( original_lines, modified_lines, fromfile=f"a/{Path(file_path).name}", tofile=f"b/{Path(file_path).name}", lineterm="" ) return "".join(diff) @logged() async def execute( self, file_path: str, old_string: str, new_string: str, replace_all: bool = False ) -> ToolResult: """ Edit a file by replacing old_string with new_string. Args: file_path: Absolute path to the file old_string: Text to find (must exist) new_string: Replacement text replace_all: Replace all occurrences (default: False) Returns: ToolResult with diff preview or error """ path = Path(file_path) # Validate path is allowed if not self._validate_path(path, self.allowed_paths): return self._error(f"Path not in allowed paths: {file_path}") # Check file exists if not path.exists(): return self._error(f"File not found: {file_path}") if not path.is_file(): return self._error(f"Not a file: {file_path}") # Check for binary files if self._is_binary_file(path): return self._error(f"Cannot edit binary file: {file_path}") # Check file size file_size = path.stat().st_size if file_size > self.max_file_size: return self._error( f"File too large ({file_size} bytes). Max: {self.max_file_size} bytes" ) # Validate inputs if not old_string: return self._error("old_string cannot be empty") if old_string == new_string: return self._error("old_string and new_string are identical") try: # Read file content async with aiofiles.open(path, 'r', encoding='utf-8', errors='replace') as f: content = await f.read() # Check if old_string exists count = content.count(old_string) if count == 0: return self._error( f"old_string not found in file. " f"Make sure to match exact whitespace and indentation." ) # Check uniqueness if replace_all is False if not replace_all and count > 1: return self._error( f"old_string appears {count} times in file. " f"Use replace_all=True to replace all, or provide a more specific string." ) # Perform replacement if replace_all: modified = content.replace(old_string, new_string) else: modified = content.replace(old_string, new_string, 1) # Generate diff for preview diff = self._generate_diff(content, modified, file_path) # Write modified content async with aiofiles.open(path, 'w', encoding='utf-8') as f: await f.write(modified) replacements = count if replace_all else 1 return self._success( data=f"Successfully edited {file_path}\n\n{diff}", replacements=replacements, file_path=str(path.resolve()) ) except PermissionError: return self._error(f"Permission denied: {file_path}") except UnicodeDecodeError as e: return self._error(f"Unable to decode file (binary?): {e}") except Exception as e: logger.exception(f"Error editing file: {file_path}") return self._error(f"Error editing file: {e}")