# Fix: Ignore redundant `include` when `path` is a specific file

## Problem

LLM agents frequently pass a specific file path (e.g. `/opt/zig/lib/std/posix.zig`)
as the `path` parameter to `grep` or `list_files`, then also supply an `include`
filter like `*.zig` or `posix.zig`. Currently both tools reject this with:

```
error: path is not a directory: /opt/zig/lib/std/posix.zig
```

The `include` filter is meaningless when `path` already points to a single file — it
should be silently dropped and the tool should operate on that file directly.

## Current behavior

In both `_grep()` and `_list_files()` (tools.py lines 577–581 and 675–678):

```python
if not root.is_dir():
    return f"error: path is not a directory: {path}"
```

This check fires before any file walking or include filtering.

## Decisions

- **Binary files in single-file mode**: Return `"No matches found."` (silent skip),
  consistent with directory-mode behavior which silently skips binary files.
- **Invalid `include` patterns**: Still validated and rejected before the path check,
  same as today. This is intentional — a malformed include like `../../../etc/passwd`
  is always an error regardless of whether path is a file or directory.
- **Schema descriptions**: Updated to say "file or directory" instead of just
  "directory".

## Proposed changes

### 1. `_grep()` — handle file path directly (tools.py ~line 675)

**Important**: `base = Path(base_dir).resolve()` is defined at line 680, *after*
the current directory check at line 677. The new single-file branch must move
after the `base` assignment, or `base` must be computed earlier.

Cleanest approach: move `base = Path(base_dir).resolve()` above the exists/is_dir
checks (before line 675), then replace the "not a directory" error:

```python
base = Path(base_dir).resolve()  # moved up from line 680

if not root.exists():
    return f"error: path does not exist: {path}"

if root.is_file():
    # path is a specific file — ignore include, grep it directly
    filepath = root
    if not _is_within_base(filepath, base,
                           unrestricted=unrestricted,
                           extra_read_roots=extra_read_roots,
                           extra_write_roots=extra_write_roots):
        return f"error: {path} is outside allowed roots"
    # Skip binary (silent, consistent with directory mode)
    try:
        with open(filepath, "rb") as f:
            chunk = f.read(BINARY_CHECK_BYTES)
    except (PermissionError, OSError):
        return "No matches found."
    if b"\x00" in chunk:
        return "No matches found."
    # Read and search
    try:
        text = filepath.read_text(encoding="utf-8")
    except (UnicodeDecodeError, PermissionError, OSError):
        return "No matches found."
    matches = []
    mtime = filepath.stat().st_mtime
    for line_no, line in enumerate(text.splitlines(), start=1):
        if regex.search(line):
            matches.append((filepath, line_no, line, mtime))
    # Fall through to the existing formatting logic (line 734+)

elif not root.is_dir():
    return f"error: path is not a directory: {path}"

else:
    # existing directory walk logic (lines 682–732)
    ...
```

The `matches` list and formatting tail (lines 734–791) already work generically
for any `matches` list, so no changes needed there.

### 2. `_list_files()` — handle file path directly (tools.py ~line 578)

Same `base` ordering issue: `base = Path(base_dir).resolve()` is at line 583,
after the directory check. Move it up.

```python
base = Path(base_dir).resolve()  # moved up from line 583

if not root.exists():
    return f"error: path does not exist: {path}"

if root.is_file():
    # path is a specific file — just return it, ignore pattern
    if not _is_within_base(root, base,
                           unrestricted=unrestricted,
                           extra_read_roots=extra_read_roots,
                           extra_write_roots=extra_write_roots):
        return f"error: {path} is outside allowed roots"
    try:
        rel = str(root.relative_to(base))
    except ValueError:
        rel = str(root)
    return rel

elif not root.is_dir():
    return f"error: path is not a directory: {path}"
```

### 3. Schema descriptions (tools.py lines 144–151, 173–179)

Update `path` descriptions for both tools:

```python
# list_files (line 147)
"description": (
    "File or directory to search in, relative to base directory. "
    'Defaults to "." (base directory).'
),

# grep (line 175)
"description": (
    "File or directory to search in, relative to base directory. "
    'Defaults to "." (base directory).'
),
```

### 4. Tests (tests/test_list_grep.py)

Add to `TestGrep`:

- **`test_grep_single_file`** — `path` points to a file, no `include`. Should
  return matches from that file.
- **`test_grep_single_file_with_include`** — `path` is a file, `include`
  matches the basename (`*.py`). Should still return matches (include ignored).
- **`test_grep_single_file_with_mismatched_include`** — `path` is a file,
  `include` is `*.txt` but file is `.py`. Should still search the file (include
  is irrelevant for a single file).
- **`test_grep_single_file_binary_skipped`** — `path` is a binary file. Should
  return `"No matches found."` (not an error).
- **`test_grep_single_file_no_match`** — `path` is a file but pattern doesn't
  match. Should return `"No matches found."`.

Add to `TestListFiles`:

- **`test_list_files_single_file`** — `path` is a file. Should return that
  file's relative path.
- **`test_list_files_single_file_with_pattern`** — `path` is a file, pattern
  is `*.txt`. Should still return the file (pattern ignored for a single file).

### 5. Scope

- `swival/tools.py`: `_grep()`, `_list_files()`, and both tool schemas
- `tests/test_list_grep.py`: 7 new test cases
