#!/usr/bin/env python3
"""
File-name search for Klea MCP tools.
File: klea_utils/mcp/tool_impls/find_files.py
Copyright 2026 Ankur Sinha
Author: Ankur Sinha <sanjay DOT ankur AT gmail dot com>
"""
import fnmatch
import logging
from pathlib import Path
from typing import Any
from klea_utils.mcp.tool_impls.file_ops import TRUNCATION_NOTE
from klea_utils.mcp.tool_impls.rg_backend import resolve_rg, rg_files
from klea_utils.mcp.tool_impls.walk import iter_files
logger = logging.getLogger(__name__)
#: Default cap on the number of file paths returned.
DEFAULT_MAX_RESULTS = 100
def _result(
*,
files: list[str] | None = None,
truncated: bool = False,
error: str = "",
note: str = "",
) -> dict[str, Any]:
"""Build the standard find_files result dict.
:param files: Matching paths, relative to the search root.
:param truncated: Whether the result was cut short by the cap.
:param error: Empty on success; a message otherwise.
:param note: Human-readable guidance; carries the truncation message when
the result was cut short by ``max_results``, and is empty otherwise.
:returns: The result dict returned to the MCP wrapper.
"""
return {
"files": files or [],
"truncated": truncated,
"error": error,
"note": note,
}
[docs]
def find_files_inhouse(
pattern: str = "*",
path: str = ".",
max_results: int = DEFAULT_MAX_RESULTS,
project_root: str | None = None,
) -> dict[str, Any]:
"""List files under a directory whose path matches a glob pattern.
This is the fallback used when the pinned ripgrep binary is not
installed. It has no ``.gitignore`` support: only the fixed
``SKIP_DIRECTORIES`` set is excluded, so gitignored files are listed.
The *pattern* is matched against each file's path relative to *path*
(``fnmatch``, where ``*`` also crosses directory separators, so
``*.py`` matches at any depth). Results are in traversal order and
not sorted.
:param pattern: Glob pattern to match against file paths; ``"*"`` lists
every file.
:param path: Directory to search.
:param max_results: Maximum number of file paths to return.
:param project_root: Retained for callers; the boundary is enforced by the
client-side gate, not here (ADR-0007 update 2026-10-08).
:returns: dict with files, truncated, error, note. ``note`` carries a
truncation message when the result was cut short by *max_results*,
and is empty otherwise.
"""
logger.debug(
f"Finding files\n{pattern = }\n{path = }\n{max_results = }\n{project_root = }"
)
the_path = Path(path)
if not the_path.is_dir():
logger.warning(f"Not a directory: {path}")
return _result(error=f"Not a directory: {path}")
match_all = not pattern or pattern == "*"
files: list[str] = []
try:
for candidate in iter_files(the_path, project_root=project_root):
rel = candidate.relative_to(the_path).as_posix()
if not match_all and not fnmatch.fnmatch(rel, pattern):
continue
files.append(rel)
if len(files) > max_results:
break
except OSError as exc:
logger.warning(f"Could not search {path}: {exc}")
return _result(error=f"Could not search {path}: {exc}")
truncated = len(files) > max_results
files = files[:max_results]
note = TRUNCATION_NOTE.format(max_results=max_results) if truncated else ""
logger.debug(f"Found files\n{len(files) = }\n{truncated = }")
return _result(files=files, truncated=truncated, note=note)
[docs]
async def find_files(
pattern: str = "*",
path: str = ".",
include_ignored: bool = False,
max_results: int = DEFAULT_MAX_RESULTS,
project_root: str | None = None,
) -> dict[str, Any]:
"""List files under a directory whose path matches a glob pattern.
Uses the pinned ripgrep binary when it is installed (the ``[search]``
extra), otherwise falls back to :func:`find_files_inhouse`. With
ripgrep the default honours ``.gitignore``; pass *include_ignored* to
list ignored files too. The in-house fallback has no ``.gitignore``
support, so *include_ignored* has no effect there.
:param pattern: Glob pattern matched against file paths; ``"*"`` lists
every file.
:param path: Directory to search, relative to *project_root*.
:param include_ignored: When ``True``, also list ``.gitignore``-ignored
files (ripgrep backend only).
:param max_results: Maximum number of file paths to return.
:param project_root: Retained for callers; the boundary is enforced
by the client-side gate, not here (ADR-0007 update 2026-10-08).
:returns: dict with files, truncated, error, note. ``note`` carries a
truncation message when the result was cut short by *max_results*,
and is empty otherwise.
"""
rg = resolve_rg()
if rg is not None:
logger.debug(f"Finding files with ripgrep\n{rg = }")
return await rg_files(
rg,
pattern=pattern,
path=path,
include_ignored=include_ignored,
max_results=max_results,
project_root=project_root,
)
logger.debug("ripgrep unavailable; finding files with the in-house walker")
return find_files_inhouse(
pattern=pattern,
path=path,
max_results=max_results,
project_root=project_root,
)