Newer
Older
navi-1 / navi / tools / _filesystem / write.py
"""`filesystem_write` — creating, editing and managing files."""

from __future__ import annotations

import asyncio
import shutil
from pathlib import Path

from .._internal.base import ToolContext, ToolResult
from .base import FilesystemBaseTool
from .helpers import (
    _apply_ops,
    _atomic_write,
    _context_around,
    _fmt_size,
    _looks_numbered,
    _nearest_lines,
    _unified_diff,
    _validate_ops,
)

_WRITE_ACTIONS = (
    "write",
    "append",
    "edit",
    "edit_lines",
    "insert",
    "mkdir",
    "move",
    "copy",
    "delete",
)
_READ_ACTIONS = (
    "read",
    "list",
    "find",
    "find_up",
    "grep",
    "info",
    "exists",
    "diff",
)


class FilesystemWriteTool(FilesystemBaseTool):
    name = "filesystem_write"
    description = (
        "Create and change files. edit replaces exact text that must occur once; "
        "edit_lines changes 1-based line ranges; insert adds lines; write rewrites a "
        "whole file. Actions: write, append, edit, edit_lines, insert, mkdir, move, "
        "copy, delete. Prefer edit/edit_lines over rewriting. "
        "Details: tool_manual('filesystem_write')."
    )
    parameters = {
        "type": "object",
        "properties": {
            "action": {
                "type": "string",
                "enum": list(_WRITE_ACTIONS),
                "description": "Write operation to perform.",
            },
            "path": {
                "type": "string",
                "description": "File or directory path (~ is expanded).",
            },
            "content": {
                "type": "string",
                "description": "Text for write, append or insert.",
            },
            "old": {
                "type": "string",
                "description": "Exact text to replace for edit; must occur exactly once.",
            },
            "new": {
                "type": "string",
                "description": "Replacement text for edit.",
            },
            "replace_all": {
                "type": "boolean",
                "description": "Replace every occurrence of old (default false).",
            },
            "operations": {
                "type": "array",
                "description": (
                    "Line edits for edit_lines: 1-based inclusive. insert uses 'after' "
                    "(0 means before line 1)."
                ),
                "items": {
                    "type": "object",
                    "properties": {
                        "op": {"type": "string", "enum": ["replace", "delete", "insert"]},
                        "start": {"type": "integer"},
                        "end": {"type": "integer"},
                        "after": {"type": "integer"},
                        "content": {"type": "string"},
                    },
                    "required": ["op"],
                },
            },
            "after": {
                "type": "integer",
                "description": "Line number after which insert adds content (0 = before line 1).",
            },
            "destination": {
                "type": "string",
                "description": "Target path for move or copy.",
            },
            "overwrite": {
                "type": "boolean",
                "description": "Allow move/copy to replace an existing destination.",
            },
            "recursive": {
                "type": "boolean",
                "description": "Allow delete to remove a non-empty directory.",
            },
            "force": {
                "type": "boolean",
                "description": "Allow write with empty content over an existing file.",
            },
        },
        "required": ["action", "path"],
    }

    async def execute(self, params: dict, ctx: ToolContext | None = None) -> ToolResult:
        action = params.get("action", "")
        raw_path = params.get("path", "")
        path = self._resolve_path(raw_path, ctx)
        if (err := self._path_result(raw_path, path)) is not None:
            return err
        assert path is not None

        try:
            match action:
                case "write":
                    return await asyncio.to_thread(self._write, path, params)
                case "append":
                    return await asyncio.to_thread(self._append, path, params)
                case "edit":
                    return await asyncio.to_thread(self._edit, path, params)
                case "edit_lines":
                    return await asyncio.to_thread(self._edit_lines, path, params)
                case "insert":
                    return await asyncio.to_thread(self._insert, path, params)
                case "mkdir":
                    return await asyncio.to_thread(self._mkdir, path)
                case "move":
                    return await asyncio.to_thread(self._move, path, params, ctx)
                case "copy":
                    return await asyncio.to_thread(self._copy, path, params, ctx)
                case "delete":
                    return await asyncio.to_thread(self._delete, path, params)
                case action if action in _READ_ACTIONS:
                    return self._redirect(action, "filesystem_read")
                case "query":
                    return ToolResult(
                        success=False,
                        output="'query' is in file_ai: call file_ai with action='ask'.",
                        error="wrong_tool",
                    )
                case "smart_edit":
                    return ToolResult(
                        success=False,
                        output="'smart_edit' is in file_ai: call file_ai with action='edit'.",
                        error="wrong_tool",
                    )
                case _:
                    return ToolResult(
                        success=False,
                        output=(
                            f"Unknown action: {action!r}. "
                            f"filesystem_write actions: {', '.join(_WRITE_ACTIONS)}."
                        ),
                        error="invalid_action",
                    )
        except PermissionError as e:
            return ToolResult(success=False, output=f"Permission denied: {e}", error=str(e))
        except Exception as e:
            return ToolResult(success=False, output=f"Filesystem error: {e}", error=str(e))

    # ── write / append ──────────────────────────────────────────────────────

    def _write(self, path: Path, params: dict) -> ToolResult:
        content = params.get("content")
        if content is None:
            return ToolResult(
                success=False, output="'content' is required for write.", error="missing_content"
            )
        if path.exists() and path.is_dir():
            return ToolResult(
                success=False, output="write works on files, not directories.", error="is_directory"
            )

        old_text: str | None = None
        old_lines: int | None = None
        if path.exists():
            old_text = path.read_text(encoding="utf-8", errors="replace")
            old_lines = len(old_text.splitlines())
            if content == "" and not params.get("force", False):
                return ToolResult(
                    success=False,
                    output=(
                        f"Refusing to overwrite {path} with empty content. "
                        "Pass force=true if this is intentional."
                    ),
                    error="empty_overwrite",
                )

        path.parent.mkdir(parents=True, exist_ok=True)
        _atomic_write(path, content)

        new_lines = len(content.splitlines())
        if old_lines is None:
            output = f"Wrote {_fmt_size(len(content.encode()))} ({new_lines} lines) → {path}"
            return ToolResult(success=True, output=output)

        output = (
            f"Overwrote {path}: {old_lines} → {new_lines} lines "
            f"({_fmt_size(len(content.encode()))})."
        )
        if old_lines and new_lines < old_lines * 0.5:
            output += (
                "\n⚠ New content is much shorter than the old file. "
                "If this was a partial edit, use edit or edit_lines instead."
            )
        metadata = {}
        if old_text is not None and len(old_text) < 1_000_000 and len(content) < 1_000_000:
            diff = _unified_diff(old_text.splitlines(), content.splitlines(), path)
            if diff:
                metadata["diff"] = diff
        return ToolResult(success=True, output=output, metadata=metadata)

    def _append(self, path: Path, params: dict) -> ToolResult:
        content = params.get("content")
        if not content:
            return ToolResult(
                success=False,
                output="'content' is required for append and must not be empty.",
                error="missing_content",
            )
        if path.exists() and path.is_dir():
            return ToolResult(
                success=False, output="append works on files, not directories.", error="is_directory"
            )
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("a", encoding="utf-8") as f:
            f.write(content)
        return ToolResult(
            success=True,
            output=(
                f"Appended {_fmt_size(len(content.encode()))} to {path} "
                f"(file now {_fmt_size(path.stat().st_size)})"
            ),
        )

    # ── edit ────────────────────────────────────────────────────────────────

    def _edit(self, path: Path, params: dict) -> ToolResult:
        old = params.get("old")
        new = params.get("new")
        if old is None:
            return ToolResult(
                success=False, output="'old' is required for edit.", error="missing_old"
            )
        if new is None:
            return ToolResult(
                success=False, output="'new' is required for edit.", error="missing_new"
            )
        if old == "":
            return ToolResult(success=False, output="'old' must not be empty.", error="empty_old")
        if not path.exists():
            return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
        if path.is_dir():
            return ToolResult(
                success=False, output="edit works on files, not directories.", error="is_directory"
            )

        text = path.read_text(encoding="utf-8", errors="replace")
        count = text.count(old)
        replace_all = bool(params.get("replace_all", False))

        if count == 0:
            return self._old_not_found(path, text, old)

        if count > 1 and not replace_all:
            positions: list[int] = []
            start = 0
            while len(positions) < 20:
                index = text.find(old, start)
                if index < 0:
                    break
                positions.append(text.count("\n", 0, index) + 1)
                start = index + max(1, len(old))
            where = ", ".join(str(n) for n in positions)
            return ToolResult(
                success=False,
                output=(
                    f"Exact text occurs {count} times in {path} at lines: {where}.\n"
                    "Include more surrounding lines so it matches once, use edit_lines with "
                    "line numbers, or set replace_all=true to replace every occurrence."
                ),
                error="old_not_unique",
            )

        first_line = text.count("\n", 0, text.find(old)) + 1
        new_text = text.replace(old, new, -1 if replace_all else 1)
        _atomic_write(path, new_text)

        old_lines = text.splitlines()
        new_lines = new_text.splitlines()
        replaced = count if replace_all else 1
        summary = (
            f"Edited {path}: replaced {replaced} occurrence(s), "
            f"{len(old.encode())} B → {len(new.encode())} B."
        )
        context = _context_around(new_lines, [first_line], radius=2)
        output = f"{summary}\n\n{context}" if context else summary
        diff = _unified_diff(old_lines, new_lines, path)
        return ToolResult(
            success=True,
            output=output,
            metadata={"diff": diff} if diff else {},
        )

    def _old_not_found(self, path: Path, text: str, old: str) -> ToolResult:
        parts = [f"Exact text not found in {path}."]
        if _looks_numbered(old):
            parts.append(
                "The 'old' text looks like it includes the line-number column from read. "
                "Read with numbered=false and copy only the file text."
            )
        candidates = _nearest_lines(text, old)
        if candidates:
            parts.append("Closest lines:")
            parts.extend(f"  {number}: {line}" for number, line in candidates)
        parts.append(
            "Read the relevant section and call edit again with text copied exactly "
            "from the file, including indentation."
        )
        return ToolResult(success=False, output="\n".join(parts), error="old_not_found")

    def _edit_lines(self, path: Path, params: dict) -> ToolResult:
        ops = params.get("operations")
        if not ops:
            return ToolResult(
                success=False,
                output="'operations' is required for edit_lines.",
                error="missing_operations",
            )
        if not path.exists():
            return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
        if path.is_dir():
            return ToolResult(
                success=False,
                output="edit_lines works on files, not directories.",
                error="is_directory",
            )

        text = path.read_text(encoding="utf-8", errors="replace")
        lines = text.splitlines()
        errors = _validate_ops(ops, len(lines))
        if errors:
            return ToolResult(
                success=False,
                output=(
                    f"Invalid operations for {path.name} ({len(lines)} lines):\n"
                    + "\n".join(f"  • {e}" for e in errors)
                    + "\nLines are 1-based and inclusive."
                ),
                error="invalid_ops",
            )
        new_lines = _apply_ops(lines, ops)
        diff = _unified_diff(lines, new_lines, path)

        new_text = "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
        _atomic_write(path, new_text)

        summary = (
            f"Applied {len(ops)} operation(s) to {path.name} "
            f"({len(lines)} → {len(new_lines)} lines)."
        )
        return ToolResult(success=True, output=f"{summary}\n\n{diff}" if diff else summary)

    def _insert(self, path: Path, params: dict) -> ToolResult:
        after = params.get("after")
        content = params.get("content")
        if not isinstance(after, int) or after < 0:
            return ToolResult(
                success=False,
                output="'after' must be an integer >= 0 (0 = before line 1).",
                error="missing_after",
            )
        if content is None:
            return ToolResult(
                success=False, output="'content' is required for insert.", error="missing_content"
            )
        if not path.exists():
            return ToolResult(success=False, output=f"File not found: {path}", error="not_found")
        if path.is_dir():
            return ToolResult(
                success=False, output="insert works on files, not directories.", error="is_directory"
            )

        text = path.read_text(encoding="utf-8", errors="replace")
        lines = text.splitlines()
        if after > len(lines):
            return ToolResult(
                success=False,
                output=f"'after'={after} is beyond the file (0–{len(lines)}).",
                error="invalid_after",
            )

        inserted = content.split("\n")
        new_lines = lines[:after] + inserted + lines[after:]
        new_text = "\n".join(new_lines) + ("\n" if text.endswith("\n") else "")
        _atomic_write(path, new_text)
        diff = _unified_diff(lines, new_lines, path)
        summary = (
            f"Inserted {len(inserted)} line(s) after line {after} in {path.name} "
            f"({len(lines)} → {len(new_lines)} lines)."
        )
        return ToolResult(success=True, output=f"{summary}\n\n{diff}" if diff else summary)

    # ── directories and paths ───────────────────────────────────────────────

    def _mkdir(self, path: Path) -> ToolResult:
        if path.exists() and not path.is_dir():
            return ToolResult(
                success=False,
                output=f"Path exists and is not a directory: {path}",
                error="not_a_directory",
            )
        path.mkdir(parents=True, exist_ok=True)
        return ToolResult(success=True, output=f"Directory created: {path}")

    def _move(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
        dest_raw = params.get("destination")
        if not dest_raw:
            return ToolResult(
                success=False,
                output="'destination' is required for move.",
                error="missing_destination",
            )
        dest = self._resolve_path(dest_raw, ctx)
        if dest is None:
            return ToolResult(
                success=False,
                output=f"Access denied: destination '{dest_raw}' outside allowed paths.",
                error="access_denied",
            )
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
        overwrite = bool(params.get("overwrite", False))
        if dest.exists() and not overwrite:
            return ToolResult(
                success=False,
                output=(
                    f"Destination already exists: {dest}. "
                    "Pass overwrite=true to replace it."
                ),
                error="destination_exists",
            )
        dest.parent.mkdir(parents=True, exist_ok=True)
        if dest.exists():
            if dest.is_dir():
                shutil.rmtree(dest)
            else:
                dest.unlink()
        shutil.move(str(path), str(dest))
        return ToolResult(success=True, output=f"Moved: {path} → {dest}")

    def _copy(self, path: Path, params: dict, ctx: ToolContext | None) -> ToolResult:
        dest_raw = params.get("destination")
        if not dest_raw:
            return ToolResult(
                success=False,
                output="'destination' is required for copy.",
                error="missing_destination",
            )
        dest = self._resolve_path(dest_raw, ctx)
        if dest is None:
            return ToolResult(
                success=False,
                output=f"Access denied: destination '{dest_raw}' outside allowed paths.",
                error="access_denied",
            )
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
        overwrite = bool(params.get("overwrite", False))
        if dest.exists() and not overwrite:
            return ToolResult(
                success=False,
                output=(
                    f"Destination already exists: {dest}. "
                    "Pass overwrite=true to replace it."
                ),
                error="destination_exists",
            )
        dest.parent.mkdir(parents=True, exist_ok=True)
        if dest.exists() and overwrite:
            if dest.is_dir():
                shutil.rmtree(dest)
            else:
                dest.unlink()
        if path.is_dir():
            shutil.copytree(str(path), str(dest), dirs_exist_ok=overwrite)
        else:
            shutil.copy2(str(path), str(dest))
        return ToolResult(success=True, output=f"Copied: {path} → {dest}")

    def _delete(self, path: Path, params: dict) -> ToolResult:
        if not path.exists():
            return ToolResult(success=False, output=f"Not found: {path}", error="not_found")
        if path.is_dir():
            children = list(path.iterdir())
            if children and not params.get("recursive", False):
                return ToolResult(
                    success=False,
                    output=(
                        f"{path} is a non-empty directory ({len(children)} entries). "
                        "Pass recursive=true to delete the whole tree."
                    ),
                    error="directory_not_empty",
                )
            if children:
                shutil.rmtree(path)
            else:
                path.rmdir()
            return ToolResult(success=True, output=f"Deleted directory: {path}")
        path.unlink()
        return ToolResult(success=True, output=f"Deleted: {path}")