"""Behave step implementations for repl_uncovered_paths.feature. Covers uncovered lines in ``src/cleveragents/cli/commands/repl.py``: 610-611 persona import file-not-found 619-621 persona import validation failure 625-626 unknown persona sub-command 651-652 session switch missing name 792-794 @ reference suggestions in run_repl 805 empty argv after shlex.split in run_repl """ from __future__ import annotations import os import shutil import tempfile from io import StringIO from pathlib import Path from unittest.mock import MagicMock, patch from behave import given, then, when from behave.runner import Context # =================================================================== # Helpers # =================================================================== class _FakeInputRplcov3: """Feed predefined lines then raise EOFError to exit the REPL.""" def __init__(self, lines: list[str]) -> None: self._lines = list(lines) self._idx = 0 def __call__(self, prompt: str = "") -> str: if self._idx >= len(self._lines): raise EOFError line = self._lines[self._idx] self._idx += 1 return line def _make_console_and_buf(): """Return a (Console, StringIO) pair for capturing Rich output.""" from rich.console import Console buf = StringIO() console = Console(file=buf, no_color=True, width=200) return console, buf # =================================================================== # Background # =================================================================== @given("rplcov3 the repl module is imported") def step_rplcov3_import(context: Context) -> None: import cleveragents.cli.commands.repl as mod context.rplcov3_mod = mod # =================================================================== # Shared — temporary persona registry # =================================================================== @given("rplcov3 a temporary persona registry") def step_rplcov3_temp_registry(context: Context) -> None: from cleveragents.cli.persona import PersonaRegistry config_dir = Path(tempfile.mkdtemp()) context.rplcov3_config_dir = config_dir context.add_cleanup(lambda: shutil.rmtree(str(config_dir), ignore_errors=True)) patcher = patch.dict(os.environ, {"CLEVERAGENTS_CONFIG_DIR": str(config_dir)}) patcher.start() context.add_cleanup(patcher.stop) registry = PersonaRegistry() registry.ensure_default_persona() context.rplcov3_registry = registry # Prepare default sessions dict for slash-command tests from cleveragents.cli.commands.repl import _ReplSessionState active = registry.get_active_persona_name() or "default" context.rplcov3_sessions = { "default": _ReplSessionState(name="default", active_persona=active), } context.rplcov3_current_session = "default" # =================================================================== # Slash command runner helper # =================================================================== def _run_slash_command(context: Context, slash_text: str) -> None: """Execute _handle_slash_command and capture output.""" console, buf = _make_console_and_buf() patcher = patch("cleveragents.cli.commands.repl._console", console) patcher.start() try: exit_code, session = context.rplcov3_mod._handle_slash_command( slash_text, registry=context.rplcov3_registry, sessions=context.rplcov3_sessions, current_session=context.rplcov3_current_session, ) finally: patcher.stop() context.rplcov3_exit_code = exit_code context.rplcov3_session = session context.rplcov3_output = buf.getvalue() # =================================================================== # Scenario: persona import file-not-found (lines 610-611) # =================================================================== @when('rplcov3 I handle slash command "{cmd}"') def step_rplcov3_handle_slash(context: Context, cmd: str) -> None: _run_slash_command(context, cmd) @then("rplcov3 the exit code is {code:d}") def step_rplcov3_exit_code(context: Context, code: int) -> None: assert context.rplcov3_exit_code == code, ( f"Expected exit code {code}, got {context.rplcov3_exit_code}. " f"Output: {context.rplcov3_output}" ) @then('rplcov3 the output contains "{fragment}"') def step_rplcov3_output_contains(context: Context, fragment: str) -> None: assert fragment in context.rplcov3_output, ( f"Expected '{fragment}' in output. Got: {context.rplcov3_output}" ) # =================================================================== # Scenario: persona import validation failure (lines 619-621) # =================================================================== @given("rplcov3 an importable YAML file with invalid persona data") def step_rplcov3_invalid_yaml_file(context: Context) -> None: """Create a valid YAML file whose contents fail PersonaConfig validation.""" tmp = tempfile.NamedTemporaryFile( # noqa: SIM115 suffix=".yaml", delete=False, mode="w", encoding="utf-8", ) # Missing required 'actor' field and name with path separator -> validation error tmp.write("name: test-bad\nactor: noslash\n") tmp.flush() tmp.close() context.rplcov3_invalid_yaml_path = tmp.name context.add_cleanup(lambda: os.unlink(tmp.name)) @when("rplcov3 I handle slash command for import with invalid persona file") def step_rplcov3_import_invalid_persona(context: Context) -> None: """Run persona import pointing at the invalid YAML file via a relative path.""" abs_path = Path(context.rplcov3_invalid_yaml_path) # resolve_import_path requires a relative path; mock it to return the absolute path console, buf = _make_console_and_buf() patcher_console = patch("cleveragents.cli.commands.repl._console", console) patcher_resolve = patch.object( context.rplcov3_registry, "resolve_import_path", return_value=abs_path, ) patcher_console.start() patcher_resolve.start() try: exit_code, session = context.rplcov3_mod._handle_slash_command( f"persona import {abs_path.name}", registry=context.rplcov3_registry, sessions=context.rplcov3_sessions, current_session=context.rplcov3_current_session, ) finally: patcher_console.stop() patcher_resolve.stop() context.rplcov3_exit_code = exit_code context.rplcov3_session = session context.rplcov3_output = buf.getvalue() # =================================================================== # Scenario: @ reference suggestions in run_repl (lines 792-794) # =================================================================== @given("rplcov3 a reference catalog with known entries") def step_rplcov3_reference_catalog(context: Context) -> None: """Prepare a catalog that has entries so suggestions are produced.""" context.rplcov3_catalog = { "file": ["README.md", "setup.py", "main.py"], "actor": ["local/helper"], "tool": [], "skill": [], "project": [], "plan": [], } @when('rplcov3 I run the REPL with input "@zzz_nomatch_token" and mocked catalog') def step_rplcov3_repl_at_suggestions(context: Context) -> None: """Feed the REPL a line with an @ token that won't expand but will trigger suggestions.""" from cleveragents.cli.commands.repl import run_repl console, buf = _make_console_and_buf() # The line contains @ so _build_reference_catalog is called; mock it # _expand_references will NOT change line (no fuzzy match for zzz_nomatch_token # against our catalog entries), so we fall into the elif branch (line 791). # _reference_suggestions should then return hints. catalog = context.rplcov3_catalog # Make _expand_references return the line unchanged (it will, because no match) # Make _reference_suggestions return some hints fake_input = _FakeInputRplcov3(["@file:rea"]) mock_dispatch = MagicMock(return_value=0) patches = [ patch("cleveragents.cli.commands.repl._console", console), patch("builtins.input", fake_input), patch("cleveragents.cli.commands.repl.dispatch_command", mock_dispatch), patch( "cleveragents.cli.commands.repl._build_reference_catalog", return_value=catalog, ), # Mock _expand_references to return the input unchanged so we hit the elif branch patch( "cleveragents.cli.commands.repl._expand_references", side_effect=lambda line, **kw: line, ), # Mock _reference_suggestions to return hints patch( "cleveragents.cli.commands.repl._reference_suggestions", return_value=["@file:README.md", "@file:setup.py"], ), ] for p in patches: p.start() try: code = run_repl(no_history=True) finally: for p in patches: p.stop() context.rplcov3_repl_exit_code = code context.rplcov3_repl_output = buf.getvalue() @then("rplcov3 the repl exit code is {code:d}") def step_rplcov3_repl_exit_code(context: Context, code: int) -> None: assert context.rplcov3_repl_exit_code == code, ( f"Expected REPL exit code {code}, got {context.rplcov3_repl_exit_code}. " f"Output: {context.rplcov3_repl_output}" ) @then('rplcov3 the repl output contains "{fragment}"') def step_rplcov3_repl_output_contains(context: Context, fragment: str) -> None: assert fragment in context.rplcov3_repl_output, ( f"Expected '{fragment}' in REPL output. Got: {context.rplcov3_repl_output}" ) # =================================================================== # Scenario: empty argv after shlex.split in run_repl (line 805) # =================================================================== @when("rplcov3 I run the REPL with input that tokenises to empty") def step_rplcov3_repl_empty_argv(context: Context) -> None: """Feed the REPL a line that shlex.split produces an empty list from. A line of whitespace is stripped to '' by line.strip() and caught by the `if not line: continue` guard. To reach shlex.split we need a non-empty string that tokenises to nothing. We can achieve this by passing a line consisting of only a comment character, but shlex will not produce empty there either. The simplest way: mock shlex.split to return [] for a specific line, then verify dispatch_command was never called. """ from cleveragents.cli.commands.repl import run_repl console, buf = _make_console_and_buf() # Feed a real non-empty line so it passes through all guards fake_input = _FakeInputRplcov3(["someline"]) mock_dispatch = MagicMock(return_value=0) original_shlex_split = __import__("shlex").split def fake_shlex_split(text): # Return empty list for our test line to hit line 805 if text == "someline": return [] return original_shlex_split(text) patches = [ patch("cleveragents.cli.commands.repl._console", console), patch("builtins.input", fake_input), patch("cleveragents.cli.commands.repl.dispatch_command", mock_dispatch), patch("shlex.split", side_effect=fake_shlex_split), ] for p in patches: p.start() try: code = run_repl(no_history=True) finally: for p in patches: p.stop() context.rplcov3_repl_exit_code = code context.rplcov3_repl_output = buf.getvalue() context.rplcov3_mock_dispatch = mock_dispatch @then("rplcov3 dispatch_command was not called") def step_rplcov3_dispatch_not_called(context: Context) -> None: assert not context.rplcov3_mock_dispatch.called, ( "dispatch_command should not have been called for empty argv" )