"""Step definitions for the Skill Search and Directory Tools feature.""" from __future__ import annotations import os import shutil import tempfile from pathlib import Path from typing import Any from unittest.mock import patch from behave import given, then, when from cleveragents.skills.builtins.search_ops import register_skill_search_tools from cleveragents.tool.registry import ToolRegistry from cleveragents.tool.runner import ToolRunner __all__: list[str] = [] # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _run_search_tool(context: Any, tool_name: str, inputs: dict[str, Any]) -> None: """Execute a skill search tool through the runner.""" registry = ToolRegistry() register_skill_search_tools(registry) runner = ToolRunner(registry) inputs["sandbox_root"] = context.search_sandbox_dir context.search_tool_result = runner.execute(tool_name, inputs) # --------------------------------------------------------------------------- # Givens # --------------------------------------------------------------------------- @given("a skill search sandbox directory") def step_given_search_sandbox(context: Any) -> None: context.search_sandbox_dir = tempfile.mkdtemp() context._cleanup_handlers.append( lambda: shutil.rmtree(context.search_sandbox_dir, ignore_errors=True) ) @given('a search sandbox file "{name}" with content "{content}"') def step_given_search_file(context: Any, name: str, content: str) -> None: path = Path(context.search_sandbox_dir) / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) @given('a search sandbox file "{name}" with lines "{csv_lines}"') def step_given_search_file_lines(context: Any, name: str, csv_lines: str) -> None: lines = csv_lines.split(",") path = Path(context.search_sandbox_dir) / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text("\n".join(lines) + "\n") @given('a search sandbox subdirectory "{name}"') def step_given_search_subdir(context: Any, name: str) -> None: path = Path(context.search_sandbox_dir) / name path.mkdir(parents=True, exist_ok=True) @given('a search sandbox binary file "{name}"') def step_given_search_binary(context: Any, name: str) -> None: path = Path(context.search_sandbox_dir) / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100) @given("a skill search tools registry") def step_given_search_registry(context: Any) -> None: context.search_registry = ToolRegistry() register_skill_search_tools(context.search_registry) # --------------------------------------------------------------------------- # Whens - ListDir # --------------------------------------------------------------------------- @when('I list-dir "{path}" in the search sandbox') def step_when_list_dir(context: Any, path: str) -> None: _run_search_tool(context, "skill/list-dir", {"path": path}) @when('I list-dir-typed "{path}" with type_filter "{type_filter}"') def step_when_list_dir_type(context: Any, path: str, type_filter: str) -> None: _run_search_tool( context, "skill/list-dir", {"path": path, "type_filter": type_filter}, ) @when('I list-dir-ignoring "{path}" ignoring "{pattern}"') def step_when_list_dir_ignore(context: Any, path: str, pattern: str) -> None: _run_search_tool( context, "skill/list-dir", {"path": path, "ignore_patterns": [pattern]}, ) @when('I list-dir-limited "{path}" with limit {limit:d}') def step_when_list_dir_limit(context: Any, path: str, limit: int) -> None: _run_search_tool( context, "skill/list-dir", {"path": path, "limit": limit}, ) # --------------------------------------------------------------------------- # Whens - Glob # --------------------------------------------------------------------------- @when('I glob-match "{path}" for "{pattern}"') def step_when_glob_basic(context: Any, path: str, pattern: str) -> None: _run_search_tool(context, "skill/glob", {"path": path, "pattern": pattern}) @when('I glob-exclude "{path}" for "{pattern}" excluding "{exclude}"') def step_when_glob_excl(context: Any, path: str, pattern: str, exclude: str) -> None: _run_search_tool( context, "skill/glob", { "path": path, "pattern": pattern, "exclude_patterns": [exclude], }, ) @when('I glob-include "{path}" for "{pattern}" including "{include}"') def step_when_glob_incl(context: Any, path: str, pattern: str, include: str) -> None: _run_search_tool( context, "skill/glob", { "path": path, "pattern": pattern, "include_patterns": [include], }, ) @when('I glob-limited "{path}" for "{pattern}" with limit {limit:d}') def step_when_glob_lim(context: Any, path: str, pattern: str, limit: int) -> None: _run_search_tool( context, "skill/glob", {"path": path, "pattern": pattern, "limit": limit}, ) # --------------------------------------------------------------------------- # Whens - Grep # --------------------------------------------------------------------------- @when('I grep-search "{path}" for "{pattern}"') def step_when_grep_basic(context: Any, path: str, pattern: str) -> None: _run_search_tool(context, "skill/grep", {"path": path, "pattern": pattern}) @when('I grep-context "{path}" for "{pattern}" with context {ctx:d}') def step_when_grep_ctx(context: Any, path: str, pattern: str, ctx: int) -> None: _run_search_tool( context, "skill/grep", {"path": path, "pattern": pattern, "context_lines": ctx}, ) @when('I grep-capped "{path}" for "{pattern}" with per_file_cap {cap:d}') def step_when_grep_cap(context: Any, path: str, pattern: str, cap: int) -> None: _run_search_tool( context, "skill/grep", {"path": path, "pattern": pattern, "per_file_cap": cap}, ) @when('I grep-maxed "{path}" for "{pattern}" with max_matches {max_m:d}') def step_when_grep_max(context: Any, path: str, pattern: str, max_m: int) -> None: _run_search_tool( context, "skill/grep", {"path": path, "pattern": pattern, "max_matches": max_m}, ) # --------------------------------------------------------------------------- # Thens # --------------------------------------------------------------------------- @then("the search tool result should be successful") def step_then_search_success(context: Any) -> None: result = context.search_tool_result assert result.success, f"Tool failed: {result.error}" @then("the search tool result should not be successful") def step_then_search_fail(context: Any) -> None: result = context.search_tool_result assert not result.success, "Expected failure but tool succeeded" @then('the search output "{key}" should be greater than or equal to {value:d}') def step_then_search_output_gte(context: Any, key: str, value: int) -> None: assert context.search_tool_result.output[key] >= value, ( f"Expected {key}>={value}, got {context.search_tool_result.output[key]}" ) @then('the search output "{key}" should be boolean true') def step_then_search_output_true(context: Any, key: str) -> None: assert context.search_tool_result.output[key] is True @then('the search output "{key}" should be boolean false') def step_then_search_output_false(context: Any, key: str) -> None: assert context.search_tool_result.output[key] is False @then('the search output "{key}" should equal {value:d}') def step_then_search_output_eq(context: Any, key: str, value: int) -> None: assert context.search_tool_result.output[key] == value, ( f"Expected {key}={value}, got {context.search_tool_result.output[key]}" ) @then("all search entries should not be directories") def step_then_no_dirs(context: Any) -> None: entries = context.search_tool_result.output["entries"] for e in entries: assert not e["is_dir"], f"{e['name']} is a directory" @then("all search entries should be directories") def step_then_all_dirs(context: Any) -> None: entries = context.search_tool_result.output["entries"] assert len(entries) > 0, "Expected at least one directory entry" for e in entries: assert e["is_dir"], f"{e['name']} is not a directory" @then('no search entry should have name "{name}"') def step_then_no_entry_name(context: Any, name: str) -> None: entries = context.search_tool_result.output["entries"] names = [e["name"] for e in entries] assert name not in names, f"Found unwanted entry: {name}" @then('the search tool error should mention "{text}"') def step_then_search_error(context: Any, text: str) -> None: error = context.search_tool_result.error or "" assert text.lower() in error.lower(), f"Expected '{text}' in error: {error}" @then("the search glob total should be {count:d}") def step_then_glob_total(context: Any, count: int) -> None: total = context.search_tool_result.output["total"] assert total == count, f"Expected {count} matches, got {total}" @then("the search glob total should be at least {count:d}") def step_then_glob_total_min(context: Any, count: int) -> None: total = context.search_tool_result.output["total"] assert total >= count, f"Expected >={count} matches, got {total}" @then('no search match should have name "{name}"') def step_then_no_match_name(context: Any, name: str) -> None: matches = context.search_tool_result.output["matches"] names = [m["name"] for m in matches] assert name not in names, f"Found unwanted match: {name}" @then("the search glob results should be sorted by name") def step_then_glob_sorted(context: Any) -> None: matches = context.search_tool_result.output["matches"] names = [m["name"] for m in matches] assert names == sorted(names), f"Not sorted: {names}" @then("the search grep total should be at least {count:d}") def step_then_grep_total_min(context: Any, count: int) -> None: total = context.search_tool_result.output["total"] assert total >= count, f"Expected >={count} matches, got {total}" @then("the search grep total should equal {count:d}") def step_then_grep_total_eq(context: Any, count: int) -> None: total = context.search_tool_result.output["total"] assert total == count, f"Expected {count} matches, got {total}" @then("the search grep first match should have context_before") def step_then_grep_context_before(context: Any) -> None: matches = context.search_tool_result.output["matches"] assert len(matches) > 0, "No matches found" assert "context_before" in matches[0], "Missing context_before" @then("the search grep first match should have context_after") def step_then_grep_context_after(context: Any) -> None: matches = context.search_tool_result.output["matches"] assert len(matches) > 0, "No matches found" assert "context_after" in matches[0], "Missing context_after" @then('the search grep matches should not reference "{name}"') def step_then_grep_no_file(context: Any, name: str) -> None: matches = context.search_tool_result.output["matches"] for m in matches: assert name not in m["file"], f"Found match in excluded file: {m['file']}" @then('the search tool "{name}" should have read_only capability') def step_then_search_tool_readonly(context: Any, name: str) -> None: spec = context.search_registry.get(name) assert spec is not None, f"Tool {name} not found" assert spec.capabilities.read_only, f"{name} should be read_only" @then("the search registry should contain {count:d} tools") def step_then_search_registry_count(context: Any, count: int) -> None: tools = context.search_registry.list_tools() assert len(tools) == count, f"Expected {count} tools, got {len(tools)}" @then('the search registry should contain tool "{name}"') def step_then_search_registry_has(context: Any, name: str) -> None: assert context.search_registry.get(name) is not None, ( f"Tool '{name}' not found in registry" ) # --------------------------------------------------------------------------- # Coverage: OSError fallbacks, include patterns, directory skip # --------------------------------------------------------------------------- @when("I execute list-dir with a stat failure mock") def step_list_dir_stat_failure(context: Any) -> None: # Create a broken symlink so stat() raises OSError naturally link = Path(context.search_sandbox_dir) / "statfail.txt" if link.exists() or link.is_symlink(): link.unlink() os.symlink("/nonexistent/path/target", str(link)) _run_search_tool(context, "skill/list-dir", {"path": "."}) @then('the list-dir result should contain "{name}" with size 0') def step_then_listdir_zero_size(context: Any, name: str) -> None: assert context.search_tool_result.success entries = context.search_tool_result.output["entries"] found = [e for e in entries if e["name"] == name] assert len(found) == 1, f"Expected entry '{name}', found: {entries}" assert found[0]["size"] == 0, f"Expected size 0, got {found[0]['size']}" @when('I execute glob with pattern "{pattern}" and include "{include}"') def step_glob_with_include(context: Any, pattern: str, include: str) -> None: _run_search_tool( context, "skill/glob", {"path": ".", "pattern": pattern, "include_patterns": [include]}, ) @then('the glob results should contain "{name}"') def step_then_glob_has(context: Any, name: str) -> None: assert context.search_tool_result.success entries = context.search_tool_result.output["matches"] names = [e["name"] for e in entries] assert name in names, f"Expected '{name}' in {names}" @then('the glob results should not contain "{name}"') def step_then_glob_not_has(context: Any, name: str) -> None: assert context.search_tool_result.success entries = context.search_tool_result.output["matches"] names = [e["name"] for e in entries] assert name not in names, f"Did not expect '{name}' in {names}" @when('I execute glob with a stat failure mock and pattern "{pattern}"') def step_glob_stat_failure(context: Any, pattern: str) -> None: # Create a broken symlink so stat() raises OSError naturally link = Path(context.search_sandbox_dir) / "globstat.txt" if link.exists() or link.is_symlink(): link.unlink() os.symlink("/nonexistent/path/target", str(link)) _run_search_tool(context, "skill/glob", {"path": ".", "pattern": pattern}) @then('the glob result should contain "{name}" with size 0') def step_then_glob_zero_size(context: Any, name: str) -> None: assert context.search_tool_result.success entries = context.search_tool_result.output["matches"] found = [e for e in entries if e["name"] == name] assert len(found) == 1, f"Expected '{name}', found: {entries}" assert found[0]["size"] == 0, f"Expected size 0, got {found[0]['size']}" @when('I execute grep with pattern "{pattern}" and include "{include}"') def step_grep_with_include(context: Any, pattern: str, include: str) -> None: _run_search_tool( context, "skill/grep", {"path": ".", "pattern": pattern, "include": include}, ) @then('the grep results should contain "{name}"') def step_then_grep_has_file(context: Any, name: str) -> None: assert context.search_tool_result.success matches = context.search_tool_result.output["matches"] files = [m["file"] for m in matches] assert any(name in f for f in files), f"Expected '{name}' in {files}" @then("the grep results should not contain a directory entry") def step_then_grep_no_dir(context: Any) -> None: assert context.search_tool_result.success matches = context.search_tool_result.output["matches"] for m in matches: fpath = Path(context.search_sandbox_dir) / m["file"] assert not fpath.is_dir(), f"Unexpected directory in results: {m['file']}" @when('I execute grep with a read_text failure mock and pattern "{pattern}"') def step_grep_read_failure(context: Any, pattern: str) -> None: registry = ToolRegistry() register_skill_search_tools(registry) runner = ToolRunner(registry) original_read_text = Path.read_text def _fail_read(self: Path, *a: Any, **kw: Any) -> str: if "unreadable" in str(self): raise OSError("read failed") return original_read_text(self, *a, **kw) with patch.object(Path, "read_text", _fail_read): context.search_tool_result = runner.execute( "skill/grep", { "sandbox_root": context.search_sandbox_dir, "path": ".", "pattern": pattern, "include": "*.txt", }, ) @then("the grep result should be empty") def step_then_grep_empty(context: Any) -> None: assert context.search_tool_result.success matches = context.search_tool_result.output["matches"] assert len(matches) == 0, f"Expected empty matches, got {matches}"