import logging from dataclasses import dataclass, field from pathlib import Path from yuxi.channel.hooks.hook_parser import HookParseError, HookValidationError, parse_hook_md from yuxi.channel.hooks.types import HookMetadata logger = logging.getLogger(__name__) _SKIP_DIRS = frozenset( { "__pycache__", ".git", ".svn", ".hg", ".venv", "venv", ".tox", ".eggs", ".mypy_cache", ".pytest_cache", ".ruff_cache", "node_modules", ".idea", ".vscode", } ) @dataclass class DiscoveredHook: hook_dir: Path metadata: HookMetadata @property def source(self) -> str: return str(self.hook_dir) @dataclass class ScanResult: hooks: list[DiscoveredHook] = field(default_factory=list) errors: list[str] = field(default_factory=list) @property def hook_count(self) -> int: return len(self.hooks) @property def succeeded(self) -> bool: return len(self.errors) == 0 def scan_directory(directory: Path | str, *, recursive: bool = True, max_depth: int = 10) -> ScanResult: hook_dirs = _find_hook_dirs(Path(directory), recursive=recursive, max_depth=max_depth) return _parse_hook_dirs(hook_dirs) def _find_hook_dirs(root: Path, *, recursive: bool = True, max_depth: int = 10, _depth: int = 0) -> list[Path]: root = root.resolve() if not root.is_dir(): return [] if _depth > max_depth: return [] result: list[Path] = [] candidate = root / "HOOK.md" if candidate.is_file(): result.append(root) try: for entry in sorted(root.iterdir()): if not entry.is_dir(): continue if entry.name in _SKIP_DIRS: continue candidate = entry / "HOOK.md" if candidate.is_file(): result.append(entry) elif recursive: result.extend(_find_hook_dirs(entry, recursive=recursive, max_depth=max_depth, _depth=_depth + 1)) except PermissionError: logger.warning("Permission denied scanning directory: %s", root) return result def _parse_hook_dirs(hook_dirs: list[Path]) -> ScanResult: result = ScanResult() for hook_dir in hook_dirs: hook_md_path = hook_dir / "HOOK.md" try: content = hook_md_path.read_text(encoding="utf-8") metadata = parse_hook_md(content, source_path=hook_md_path) result.hooks.append(DiscoveredHook(hook_dir=hook_dir, metadata=metadata)) logger.debug("Discovered hook: %s from %s", metadata.hook_id, hook_dir) except (HookParseError, HookValidationError) as e: result.errors.append(str(e)) logger.warning("Hook discovery failed: %s", e) except OSError as e: result.errors.append(f"Cannot read {hook_md_path}: {e}") logger.warning("Cannot read HOOK.md: %s", e) return result