ReMe/reme/steps/file_io/list.py

105 lines
4.4 KiB
Python

"""``file_list`` — enumerate files under a directory in the workspace.
Reads directly from the filesystem (``Path.iterdir`` / ``Path.rglob``),
**not** the file_store index. The store may lag behind disk during
indexing or after rapid mutations; the on-disk walk is the source of truth.
Parameters:
path — dir to list under (relative to the workspace or absolute). Empty = workspace root.
limit — cap on the number of returned items (default 100, must be > 0).
recursive — descend into subdirectories. Default False = direct children only.
No frontmatter is read. Callers needing frontmatter-based filtering
should iterate the result and call ``frontmatter_read`` per candidate.
"""
from pathlib import Path
from typing import Iterable
from ._path import resolve_path
from ..base_step import BaseStep
from ...components import R
# Default cap on returned items so huge workspaces don't blow up the response.
DEFAULT_LIMIT = 100
@R.register("list_step")
class ListStep(BaseStep):
"""Enumerate files under a directory in the workspace."""
def _fail(self, message: str, **meta) -> None:
"""Set a failed response (matches the read/edit/... fail envelope)."""
assert self.context is not None
self.context.response.success = False
self.context.response.answer = f"Error: {message}"
if meta:
self.context.response.metadata.update(meta)
def _collect_params(self) -> tuple[str, bool, int]:
"""Read ``path`` / ``recursive`` / ``limit`` from context; coerce permissively."""
assert self.context is not None
path = str(self.context.get("path") or "")
recursive = bool(self.context.get("recursive", False))
raw_limit = self.context.get("limit")
# Strings like "50" are accepted; bad/non-positive values fall back to default.
try:
limit = int(raw_limit) if raw_limit is not None else DEFAULT_LIMIT
except (TypeError, ValueError):
limit = DEFAULT_LIMIT
return path, recursive, limit if limit > 0 else DEFAULT_LIMIT
@staticmethod
def _walk_files(target_dir: Path, recursive: bool, limit: int) -> list[Path]:
"""Return up to ``limit`` regular files under ``target_dir``; short-circuits at the cap."""
entries: Iterable[Path] = target_dir.rglob("*") if recursive else target_dir.iterdir()
files: list[Path] = []
for entry in entries:
if not entry.is_file(): # skip dirs, sockets, broken links, etc.
continue
files.append(entry)
if len(files) >= limit:
break
return files
@staticmethod
def _format_relative(files: list[Path], workspace_dir: Path) -> list[str]:
"""Render as workspace-relative paths; fall back to absolute when outside the workspace."""
out: list[str] = []
for entry in files:
try:
out.append(entry.relative_to(workspace_dir).as_posix())
except ValueError:
out.append(str(entry))
return out
async def execute(self):
assert self.context is not None
path, recursive, limit = self._collect_params()
workspace_dir = Path(self.file_store.workspace_path or ".").resolve()
target_dir, err = resolve_path(workspace_dir, path, allow_empty=True)
if err or target_dir is None:
self._fail(err or "invalid path", path=path)
return None
if not target_dir.exists():
self._fail(f"directory {target_dir} does not exist", path=str(target_dir))
return None
if not target_dir.is_dir():
self._fail(f"path {target_dir} is not a directory", path=str(target_dir))
return None
items = self._format_relative(self._walk_files(target_dir, recursive, limit), workspace_dir)
self.context.response.success = True
location = path or "."
if items:
rendered_items = "\n".join(f"- {item}" for item in items)
self.context.response.answer = f"Listed {len(items)} file(s) under {location}:\n{rendered_items}"
else:
self.context.response.answer = f"No files found under {location}."
self.context.response.metadata.update({"items": items, "count": len(items)})
self.logger.info(
f"[{self.name}] listed dir={target_dir} recursive={recursive} count={len(items)} limit={limit}",
)
return self.context.response