Source code for klea_utils.mcp.tool_impls.edit_file

#!/usr/bin/env python3
"""
Exact search/replace edit implementation for the Klea MCP tools (ADR-0039).

See ``devdocs/system/edit-format-strategy.md``.

File: klea_utils/mcp/tool_impls/edit_file.py

Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail dot com>
"""

import logging
from pathlib import Path
from typing import Any

from klea_utils.mcp.errors import FileEditError
from klea_utils.mcp.tool_impls import edit_replacers, file_ops
from klea_utils.mcp.tool_impls.list_files import missing_target_note, nearby_entries

logger = logging.getLogger(__name__)


def _result(
    path: str,
    *,
    replacements: int = 0,
    additions: int = 0,
    deletions: int = 0,
    matcher: str = "",
    diff: str = "",
    error: str = "",
    nearby: list[str] | None = None,
    note: str = "",
) -> dict[str, Any]:
    """Build the standard edit result dict.

    :param path: The path edited (or attempted).
    :param replacements: Number of occurrences replaced.
    :param additions: Lines added.
    :param deletions: Lines removed.
    :param matcher: Name of the replacer that matched (empty on failure).
    :param diff: Unified diff of the change (possibly truncated).
    :param error: Empty on success; a message otherwise.
    :param nearby: Entries in the nearest existing directory (missing target).
    :param note: Human-readable note for a missing/not-a-file error.
    :returns: The result dict returned to the MCP wrapper.
    """
    return {
        "path": path,
        "replacements": replacements,
        "additions": additions,
        "deletions": deletions,
        "matcher": matcher,
        "diff": diff,
        "error": error,
        "nearby": nearby or [],
        "note": note,
    }


[docs] def edit_file( path: str, old_string: str, new_string: str, replace_all: bool = False, project_root: str | None = None, ) -> dict[str, Any]: """Replace an exact span of an existing file. Framework-agnostic implementation shared across Klea MCP servers. Apps wrap this in an MCP tool (see ``klea_utils.mcp.server.bundled_tools``). Matching normalises line endings and, when an exact match fails, tries the bounded replacer chain in :mod:`klea_utils.mcp.tool_impls.edit_replacers` (line-trimmed, block-anchor, whitespace-normalised, indentation-flexible, context-aware). The file's own line ending, BOM and mode are preserved on write, which is atomic. Unless *replace_all* is set, the matched span must be unique. :param path: File path to edit. :param old_string: Exact text to replace (must not be empty). :param new_string: Replacement text (must differ from *old_string*). :param replace_all: Replace every occurrence rather than requiring one. :param project_root: Base directory used to locate nearby entries when the target is missing; the boundary itself is enforced by the client-side gate (ADR-0007 update 2026-10-08). :returns: dict with path, replacements, additions, deletions, matcher, diff, error, nearby, note. ``nearby``/``note`` are populated on a missing/not-a-file error so the caller can see what exists instead. """ logger.debug( f"Editing file\n" f"{path = }\n" f"{len(old_string) = }\n" f"{len(new_string) = }\n" f"{replace_all = }\n" f"{project_root = }" ) the_path = Path(path) if not the_path.is_file(): # Missing target (or a directory): include the nearest existing # directory's entries so the caller can see what is actually there. directory, nearby = nearby_entries(the_path, project_root) if the_path.exists(): logger.warning(f"Not a regular file: {path}") error = f"Not a regular file: {the_path}" else: logger.warning(f"Edit target not found: {path}") error = f"File not found: {the_path}" return _result( str(the_path), error=error, nearby=nearby, note=missing_target_note(path, directory, nearby), ) try: doc = file_ops.read_whole_text(the_path) except FileEditError as exc: logger.warning(f"Cannot edit {path}: {exc}") return _result(str(the_path), error=str(exc)) content = file_ops.normalize_newlines(doc.text) old_norm = file_ops.normalize_newlines(old_string) new_norm = file_ops.normalize_newlines(new_string) logger.debug( f"Loaded file for edit\n" f"{len(content) = }\n" f"{doc.bom = }\n" f"{doc.newline = }\n" f"{doc.mode = }" ) updated, replacements, matcher, error = edit_replacers.apply_edit( content, old_norm, new_norm, replace_all ) if error: logger.debug(f"Edit rejected\n{path = }\n{error = }") return _result(str(the_path), error=error) logger.debug(f"Edit matched\n{matcher = }\n{replacements = }\n{len(updated) = }") diff, additions, deletions = file_ops.diff_payload(content, updated, str(the_path)) try: file_ops.write_whole_text( the_path, updated, bom=doc.bom, newline=doc.newline, mode=doc.mode, ) except FileEditError as exc: logger.warning(f"Could not write {path}: {exc}") return _result(str(the_path), error=str(exc)) logger.debug(f"Edited {the_path}\n{replacements = }\n{matcher = }") return _result( str(the_path), replacements=replacements, additions=additions, deletions=deletions, matcher=matcher, diff=diff, )