# Section 07: File Operations Tool (`godotiq_file_ops`)

## Overview

This section implements the `godotiq_file_ops` tool -- a filesystem-based MCP tool that provides complete file operations within a Godot project directory. It supports listing, reading, writing, moving, deleting, searching (with context lines), and tree display with type filtering. This tool runs entirely in Python with no Godot addon connection required.

**File to create:** `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/file_ops.py`

**Test file to create:** `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_file_ops.py`

**Dependencies:** None. This section is fully independent and can be implemented in parallel with other sections. Section 08 (tool registration) will register this tool in `__init__.py`.

---

## Tests First

Create `/Users/salvatorefarruggio/Desktop/godotiq/tests/test_bridge/test_file_ops.py`. The tests use `tmp_path` for real filesystem operations and `monkeypatch.setenv` to set `GODOTIQ_PROJECT_ROOT`.

```python
"""Tests for file_ops filesystem tool."""

from __future__ import annotations

import pytest

from godotiq.tools.bridge.file_ops import file_ops, _resolve, _is_protected, _matches_type_filter


@pytest.fixture
def project(tmp_path, monkeypatch):
    """Set up a fake Godot project directory with sample files."""
    monkeypatch.setenv("GODOTIQ_PROJECT_ROOT", str(tmp_path))

    # Create project.godot so it looks like a real project
    (tmp_path / "project.godot").write_text("[gd_resource]")

    # Create sample files
    (tmp_path / "main.tscn").write_text('[gd_scene format=3]')
    (tmp_path / "player.gd").write_text('extends Node\nfunc _ready():\n\tpass\n')
    (tmp_path / "icon.png").write_bytes(b'\x89PNG\r\n\x1a\n' + b'\x00' * 100)

    # Create subdirectory structure
    scripts_dir = tmp_path / "scripts"
    scripts_dir.mkdir()
    (scripts_dir / "enemy.gd").write_text('extends CharacterBody3D\nvar speed: float = 5.0\n')
    (scripts_dir / "utils.gd").write_text('class_name Utils\nstatic func helper() -> void:\n\tpass\n')

    scenes_dir = tmp_path / "scenes"
    scenes_dir.mkdir()
    (scenes_dir / "level1.tscn").write_text('[gd_scene format=3]')

    # Create excluded directories
    godot_dir = tmp_path / ".godot"
    godot_dir.mkdir()
    (godot_dir / "cache.bin").write_bytes(b'\x00' * 10)

    git_dir = tmp_path / ".git"
    git_dir.mkdir()
    (git_dir / "config").write_text("[core]")

    # Create saves dir (protected)
    saves_dir = tmp_path / "saves"
    saves_dir.mkdir()
    (saves_dir / "save1.tres").write_text("[resource]")

    return tmp_path


class TestResolve:
    """Path resolution and containment checks."""

    # Test: _resolve with res:// resolves relative to GODOTIQ_PROJECT_ROOT
    async def test_res_prefix(self, project, monkeypatch):
        """res:// paths resolve relative to project root."""

    # Test: _resolve with relative path resolves correctly
    async def test_relative_path(self, project, monkeypatch):
        """Relative paths resolve against project root."""

    # Test: _resolve with path traversal raises error
    async def test_path_traversal_blocked(self, project, monkeypatch):
        """Path traversal outside project root raises ValueError."""


class TestIsProtected:
    """Protected file detection."""

    # Test: _is_protected matches project.godot, .godot/, *.import, saves/
    def test_protected_files(self, project):
        """Known protected patterns are correctly identified."""


class TestListOperation:
    """The 'list' operation."""

    # Test: list returns directory entries
    async def test_list_root(self, project):
        """Listing project root returns top-level entries."""

    # Test: list with filter="scripts" returns only .gd files
    async def test_list_filter_scripts(self, project):
        """Type filter narrows results to matching extensions."""

    # Test: list with recursive=True includes subdirectories
    async def test_list_recursive(self, project):
        """Recursive listing includes files in subdirectories."""

    # Test: list excludes .godot, .git, __pycache__ directories
    async def test_list_excludes_system_dirs(self, project):
        """System/hidden directories are automatically excluded."""

    # Test: list on missing directory raises error
    async def test_list_missing_dir(self, project):
        """Listing a nonexistent directory returns an error."""


class TestReadOperation:
    """The 'read' operation."""

    # Test: read returns text file content
    async def test_read_text_file(self, project):
        """Reading a text file returns its content."""

    # Test: read rejects binary files (raises error)
    async def test_read_binary_rejected(self, project):
        """Attempting to read a binary file returns an error."""

    # Test: read on missing file raises error
    async def test_read_missing_file(self, project):
        """Reading a nonexistent file returns an error."""


class TestWriteOperation:
    """The 'write' operation."""

    # Test: write creates file with content
    async def test_write_creates_file(self, project):
        """Writing to a new path creates the file."""

    # Test: write rejects protected files
    async def test_write_protected_rejected(self, project):
        """Writing to a protected file returns an error."""

    # Test: write path traversal blocked
    async def test_write_traversal_blocked(self, project):
        """Write with path traversal is rejected."""


class TestMoveOperation:
    """The 'move' operation."""

    # Test: move renames file successfully
    async def test_move_renames_file(self, project):
        """Moving a file to a new name renames it on disk."""

    # Test: move rejects protected source file
    async def test_move_protected_source_rejected(self, project):
        """Cannot move a protected source file."""

    # Test: move rejects protected destination path
    async def test_move_protected_destination_rejected(self, project):
        """Cannot move into a protected destination path."""

    # Test: move path traversal blocked on both source and destination
    async def test_move_traversal_blocked(self, project):
        """Path traversal is blocked for both source and destination."""


class TestDeleteOperation:
    """The 'delete' operation."""

    # Test: delete removes file
    async def test_delete_removes_file(self, project):
        """Deleting an existing file removes it from disk."""

    # Test: delete rejects protected files
    async def test_delete_protected_rejected(self, project):
        """Cannot delete a protected file."""

    # Test: delete path traversal blocked
    async def test_delete_traversal_blocked(self, project):
        """Delete with path traversal is rejected."""


class TestSearchOperation:
    """The 'search' operation."""

    # Test: search finds matching text across files
    async def test_search_finds_matches(self, project):
        """Search returns files containing the query string."""

    # Test: search returns context lines around matches
    async def test_search_context_lines(self, project):
        """Context lines are included around each match."""

    # Test: search with filter limits to specific file types
    async def test_search_with_filter(self, project):
        """Type filter restricts which files are searched."""

    # Test: search excludes .godot, .git directories
    async def test_search_excludes_system_dirs(self, project):
        """System directories are skipped during search."""

    # Test: search caps at 50 matches
    async def test_search_max_matches_cap(self, project):
        """Results are capped at 50 matches maximum."""


class TestTreeOperation:
    """The 'tree' operation."""

    # Test: tree returns directory structure with depth control
    async def test_tree_with_depth(self, project):
        """Tree respects max_depth parameter."""

    # Test: tree with show_sizes=True includes file sizes
    async def test_tree_with_sizes(self, project):
        """File sizes are included when requested."""

    # Test: tree excludes hidden/system directories
    async def test_tree_excludes_system_dirs(self, project):
        """System directories (.godot, .git, etc.) are excluded from tree."""


class TestTypeFilters:
    """Type filter matching logic."""

    # Test: _matches_type_filter correctly matches scene extensions (.tscn, .scn)
    def test_scene_filter(self):
        """Scene filter matches .tscn and .scn extensions."""

    # Test: _matches_type_filter correctly matches script extensions (.gd)
    def test_script_filter(self):
        """Script filter matches .gd extension."""

    # Test: _matches_type_filter with "all" matches everything
    def test_all_filter(self):
        """The 'all' filter matches any file."""
```

---

## Implementation Details

### File: `/Users/salvatorefarruggio/Desktop/godotiq/src/godotiq/tools/bridge/file_ops.py`

This is a self-contained Python module. Despite living in the `tools/bridge/` directory (for organizational consistency with `script_ops`), it does NOT use the WebSocket bridge. It operates directly on the local filesystem.

### Function Signature

```python
async def file_ops(
    op: str = "list",
    path: str = "",
    query: str = "",
    filter: str = "all",
    recursive: bool = False,
    context_lines: int = 2,
    max_depth: int = 3,
    show_sizes: bool = False,
    content: str = "",
    destination: str = "",
) -> dict:
    """Execute filesystem operations within the Godot project."""
```

### Type Filters

Define a `TYPE_FILTERS` dictionary mapping filter names to sets of file extensions:

| Filter | Extensions |
|--------|-----------|
| `scenes` | `.tscn`, `.scn` |
| `scripts` | `.gd` |
| `images` | `.png`, `.jpg`, `.jpeg`, `.webp`, `.svg`, `.bmp`, `.tga` |
| `audio` | `.wav`, `.ogg`, `.mp3` |
| `fonts` | `.ttf`, `.otf`, `.woff`, `.woff2` |
| `models` | `.glb`, `.gltf`, `.obj`, `.fbx`, `.dae` |
| `shaders` | `.gdshader` |
| `resources` | `.tres`, `.res` |

### Excluded Directories

Define an `EXCLUDED_DIRS` set: `.godot`, `.git`, `__pycache__`, `node_modules`, `.import`. These are always excluded from list, search, and tree operations.

### Helper Functions

**`_resolve(path: str) -> Path`**

Resolves a `res://` or relative path to an absolute `Path` within the project root. Uses `GODOTIQ_PROJECT_ROOT` environment variable. After resolving, call `Path.resolve()` and verify the resolved path starts with the project root path (containment check). Raise `ValueError` if the path escapes the project root. This is the same pattern used in `script_ops` (section 06).

**`_is_protected(path: Path) -> bool`**

Returns `True` if the path matches any protected pattern:
- Filename is `project.godot`
- Any path component is `.godot`
- File extension is `.import`
- Any path component is `saves`

**`_matches_type_filter(filename: str, filter_name: str) -> bool`**

Returns `True` if the filename matches the given filter. If `filter_name` is `"all"`, always returns `True`. Otherwise, look up the filter in `TYPE_FILTERS` and check if the file's suffix (lowercased) is in the extension set.

### Operation Implementations

**`list` operation:**
- Resolve the path (default to project root if empty)
- Verify it is a directory
- Iterate entries, skip those in `EXCLUDED_DIRS`
- Apply type filter via `_matches_type_filter`
- If `recursive=True`, walk subdirectories (still skipping excluded dirs)
- Return `{"op": "list", "path": ..., "entries": [{"name": ..., "type": "file"|"dir", ...}]}`

**`read` operation:**
- Resolve the path, verify file exists
- Attempt to read as UTF-8 text; if decoding fails, return an error indicating binary file
- Return `{"op": "read", "path": ..., "content": ..., "lines": ..., "size": ...}`

**`write` operation:**
- Resolve the path, check containment
- Check `_is_protected` -- reject if protected
- The tool description should note: use `script_ops` for `.gd` files (which provides GDScript validation)
- Create parent directories if needed
- Write content as UTF-8
- Return `{"op": "write", "path": ..., "size": ..., "created": True|False}`

**`move` operation:**
- Resolve BOTH source path and destination path
- Check `_is_protected` on BOTH source AND destination -- reject if either is protected
- Verify source exists
- Use `Path.rename()` or `shutil.move()` to move
- Return `{"op": "move", "source": ..., "destination": ..., "success": True}`

**`delete` operation:**
- Resolve the path, check containment
- Check `_is_protected` -- reject if protected
- Verify file exists
- Remove with `Path.unlink()`
- Return `{"op": "delete", "path": ..., "success": True}`

**`search` operation:**
- Resolve path (default to project root if empty)
- Walk the directory tree recursively, skipping `EXCLUDED_DIRS`
- Apply type filter to restrict which files are searched
- For each matching text file, search for `query` in each line
- Collect matches with surrounding context lines (controlled by `context_lines` parameter)
- Cap results at **50 matches** maximum
- Return `{"op": "search", "query": ..., "matches": [...], "total_matches": ..., "capped": True|False}`

Each match entry should include: `file` (relative path), `line` (line number), `content` (the matching line), `context_before` (list of lines before), `context_after` (list of lines after).

**`tree` operation:**
- Resolve path (default to project root if empty)
- Build a recursive directory structure up to `max_depth`
- Skip `EXCLUDED_DIRS`
- If `show_sizes=True`, include file sizes in bytes
- Return `{"op": "tree", "path": ..., "tree": {...}}`

The tree structure should be a nested dict with `name`, `type` (`"file"` or `"dir"`), optional `size`, and optional `children` for directories.

### Error Handling

All operations should return error dicts rather than raising exceptions (consistent with MCP tool patterns). Format: `{"error": "description of what went wrong"}`. The MCP registration wrapper (section 08) adds a catch-all `Exception` handler around the tool.

Specific error conditions:
- Missing `GODOTIQ_PROJECT_ROOT` env var: `{"error": "GODOTIQ_PROJECT_ROOT not set"}`
- Path traversal attempt: `{"error": "Path is outside the project root"}`
- Protected file operation: `{"error": "Cannot modify protected file: <path>"}`
- File not found: `{"error": "File not found: <path>"}`
- Directory not found: `{"error": "Directory not found: <path>"}`
- Binary file read: `{"error": "Cannot read binary file: <path>"}`
- Unknown operation: `{"error": "Unknown operation: <op>"}`

### Module Structure

```python
"""File operations tool — filesystem operations within the Godot project."""

from __future__ import annotations

import os
from pathlib import Path

TYPE_FILTERS: dict[str, set[str]] = {
    "scenes": {".tscn", ".scn"},
    "scripts": {".gd"},
    "images": {".png", ".jpg", ".jpeg", ".webp", ".svg", ".bmp", ".tga"},
    "audio": {".wav", ".ogg", ".mp3"},
    "fonts": {".ttf", ".otf", ".woff", ".woff2"},
    "models": {".glb", ".gltf", ".obj", ".fbx", ".dae"},
    "shaders": {".gdshader"},
    "resources": {".tres", ".res"},
}

EXCLUDED_DIRS: set[str] = {".godot", ".git", "__pycache__", "node_modules", ".import"}

MAX_SEARCH_MATCHES: int = 50


def _resolve(path: str) -> Path:
    """Resolve a res:// or relative path to an absolute Path within the project root."""

def _is_protected(path: Path) -> bool:
    """Check if a path matches protected file patterns."""

def _matches_type_filter(filename: str, filter_name: str) -> bool:
    """Check if a filename matches the given type filter."""


async def file_ops(op: str = "list", path: str = "", ...) -> dict:
    """Execute filesystem operations within the Godot project."""
```

### Relationship to script_ops (Section 06)

Both `file_ops` and `script_ops` share the same path resolution and protected file patterns. The implementer can either:
1. Duplicate the logic (simpler, keeps modules independent), or
2. Extract shared helpers into a common utility module.

Option 1 is recommended to keep sections independently implementable. The `write` operation in `file_ops` is meant for non-script files. The MCP tool description (added in section 08) should note that `.gd` files should use `script_ops` instead, which provides GDScript validation.

---

## Implementation Notes

**Status:** Complete
**Files created:**
- `src/godotiq/tools/bridge/file_ops.py` — All 7 operations implemented (list, read, write, move, delete, search, tree)
- `tests/test_bridge/test_file_ops.py` — 33 tests, all passing

**Deviations from plan:**
- Added `_get_root()` helper to reduce duplication of env-var lookup between `_resolve` and operation functions
- `_is_protected` takes `Path` (not `str` as in script_ops) but computes `relative_to(root)` internally to avoid false positives when project root path contains protected-sounding directory names (e.g. `/home/user/saves/project/`)
- Added empty-query validation in search operation (not in original spec but prevents nonsensical results)

**Code review fixes applied:**
- C2: `_is_protected` now uses relative path parts to avoid false positives
- M5: Empty search query returns error instead of matching every line
- m3/m4: Improved test assertions for tree depth and filter operations
