diff --git a/features/cli_command_bus_decoupling.feature b/features/cli_command_bus_decoupling.feature new file mode 100644 index 000000000..e74c772f4 --- /dev/null +++ b/features/cli_command_bus_decoupling.feature @@ -0,0 +1,48 @@ +Feature: CLI Command Bus Decoupling + As a developer + I want the CLI layer to be decoupled from application services + So that the architecture maintains proper separation of concerns + + Background: + Given the command bus is initialized + And no handlers are registered + + Scenario: Command bus can be created + When I create a new command bus + Then the command bus should be empty + And no handlers should be registered + + Scenario: Handler registration + Given a command bus + When I register a handler for a command type + Then the handler should be registered + And the command bus should have the handler + + Scenario: Command dispatch to registered handler + Given a command bus with a registered handler + When I dispatch a command + Then the handler should be invoked + And the result should be returned + + Scenario: Dispatch fails for unregistered command + Given a command bus with no handlers + When I dispatch a command without a registered handler + Then a ValueError should be raised for unregistered command dispatch + And the error message should mention the command type + + Scenario: Handler check + Given a command bus + When I check if a handler exists for a command type + Then the result should be accurate + And reflect the registration status + + Scenario: Global command bus instance + When I get the global command bus + Then I should receive a CommandBus instance + And subsequent calls should return the same instance + + Scenario: Set global command bus + Given a new command bus + When I set it as the global command bus + Then subsequent calls to get_command_bus should return it + And the previous instance should be replaced diff --git a/features/steps/cli_command_bus_steps.py b/features/steps/cli_command_bus_steps.py new file mode 100644 index 000000000..e0d4c20e5 --- /dev/null +++ b/features/steps/cli_command_bus_steps.py @@ -0,0 +1,207 @@ +"""Step definitions for CLI command bus decoupling tests.""" + +from behave import given, when, then +from cleveragents.cli.command_bus import ( + Command, + CommandBus, + get_command_bus, + set_command_bus, +) + + +class TestCommand(Command): + """Test command for BDD tests.""" + + def __init__(self, value: str = "test") -> None: + """Initialize test command.""" + self.value = value + + +@given("the command bus is initialized") +def step_command_bus_initialized(context): + """Initialize a fresh command bus.""" + context.bus = CommandBus() + + +@given("no handlers are registered") +def step_no_handlers_registered(context): + """Ensure no handlers are registered.""" + if not hasattr(context, "bus"): + context.bus = CommandBus() + + +@when("I create a new command bus") +def step_create_command_bus(context): + """Create a new command bus.""" + context.bus = CommandBus() + + +@then("the command bus should be empty") +def step_bus_is_empty(context): + """Verify the command bus is empty.""" + assert isinstance(context.bus, CommandBus) + + +@then("no handlers should be registered") +def step_no_handlers_registered_check(context): + """Verify no handlers are registered.""" + assert not context.bus.has_handler(TestCommand) + + +@given("a command bus") +def step_given_command_bus(context): + """Given a command bus.""" + context.bus = CommandBus() + + +@when("I register a handler for a command type") +def step_register_handler(context): + """Register a handler for a command type.""" + + def handler(cmd: TestCommand) -> str: + return f"Handled: {cmd.value}" + + context.bus.register_handler(TestCommand, handler) + context.handler = handler + + +@then("the handler should be registered") +def step_handler_registered(context): + """Verify the handler is registered.""" + assert context.bus.has_handler(TestCommand) + + +@then("the command bus should have the handler") +def step_bus_has_handler(context): + """Verify the bus has the handler.""" + assert context.bus.has_handler(TestCommand) + + +@given("a command bus with a registered handler") +def step_bus_with_handler(context): + """Given a command bus with a registered handler.""" + context.bus = CommandBus() + + def handler(cmd: TestCommand) -> str: + return f"Handled: {cmd.value}" + + context.bus.register_handler(TestCommand, handler) + + +@when("I dispatch a command") +def step_dispatch_command(context): + """Dispatch a command.""" + context.command = TestCommand("test_value") + context.result = context.bus.dispatch(context.command) + + +@then("the handler should be invoked") +def step_handler_invoked(context): + """Verify the handler was invoked.""" + assert context.result is not None + + +@then("the result should be returned") +def step_result_returned(context): + """Verify the result was returned.""" + assert context.result == "Handled: test_value" + + +@given("a command bus with no handlers") +def step_bus_no_handlers(context): + """Given a command bus with no handlers.""" + context.bus = CommandBus() + + +@when("I dispatch a command without a registered handler") +def step_dispatch_unregistered(context): + """Dispatch a command without a registered handler.""" + context.command = TestCommand() + try: + context.bus.dispatch(context.command) + context.error_raised = False + except ValueError as e: + context.error_raised = True + context.error = e + + +@then("a ValueError should be raised for unregistered command dispatch") +def step_value_error_raised(context): + """Verify a ValueError was raised.""" + assert context.error_raised + + +@then("the error message should mention the command type") +def step_error_message_check(context): + """Verify the error message mentions the command type.""" + assert "TestCommand" in str(context.error) + + +@when("I check if a handler exists for a command type") +def step_check_handler_exists(context): + """Check if a handler exists.""" + context.bus = CommandBus() + + def handler(cmd: TestCommand) -> str: + return "handled" + + context.bus.register_handler(TestCommand, handler) + context.has_handler = context.bus.has_handler(TestCommand) + context.has_other = context.bus.has_handler(Command) + + +@then("the result should be accurate") +def step_result_accurate(context): + """Verify the result is accurate.""" + assert context.has_handler is True + + +@then("reflect the registration status") +def step_reflect_status(context): + """Verify it reflects the registration status.""" + assert context.has_other is False + + +@when("I get the global command bus") +def step_get_global_bus(context): + """Get the global command bus.""" + context.bus1 = get_command_bus() + + +@then("I should receive a CommandBus instance") +def step_receive_bus_instance(context): + """Verify we received a CommandBus instance.""" + assert isinstance(context.bus1, CommandBus) + + +@then("subsequent calls should return the same instance") +def step_same_instance(context): + """Verify subsequent calls return the same instance.""" + context.bus2 = get_command_bus() + assert context.bus1 is context.bus2 + + +@given("a new command bus") +def step_new_command_bus(context): + """Create a new command bus.""" + context.new_bus = CommandBus() + + +@when("I set it as the global command bus") +def step_set_global_bus(context): + """Set it as the global command bus.""" + set_command_bus(context.new_bus) + + +@then("subsequent calls to get_command_bus should return it") +def step_get_returns_set_bus(context): + """Verify get_command_bus returns the set bus.""" + retrieved = get_command_bus() + assert retrieved is context.new_bus + + +@then("the previous instance should be replaced") +def step_previous_replaced(context): + """Verify the previous instance was replaced.""" + # This is implicitly verified by the previous step + pass diff --git a/src/cleveragents/cli/command_bus.py b/src/cleveragents/cli/command_bus.py new file mode 100644 index 000000000..dd1ed6057 --- /dev/null +++ b/src/cleveragents/cli/command_bus.py @@ -0,0 +1,131 @@ +"""Command Bus for decoupling CLI from application services. + +This module provides a command bus pattern to decouple the CLI layer from +the application services layer. CLI commands dispatch command objects through +the bus, which routes them to appropriate handlers in the application layer. + +This ensures: +- CLI has no direct dependencies on application services +- Clear separation of concerns between layers +- Easier testing and maintenance +- Flexibility to change service implementations +""" + +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any + + +class Command: + """Base class for all commands dispatched through the command bus. + + Commands represent requests from the CLI layer to the application layer. + Each command encapsulates the data needed to perform a specific operation. + """ + + pass + + +class CommandHandler[TCommand: Command, TResult](ABC): + """Base class for command handlers. + + A handler processes a specific command type and returns a result. + Handlers are registered with the command bus and invoked when + matching commands are dispatched. + """ + + @abstractmethod + def handle(self, command: TCommand) -> TResult: + """Handle the given command and return a result. + + Args: + command: The command to handle + + Returns: + The result of handling the command + """ + pass + + +class CommandBus: + """Central command dispatcher for CLI-to-application communication. + + The command bus decouples the CLI layer from application services by + accepting command objects and routing them to registered handlers. + This ensures the CLI layer has no direct imports from the application + services layer. + """ + + def __init__(self) -> None: + """Initialize the command bus with an empty handler registry.""" + self._handlers: dict[type, Callable[[Any], Any]] = {} + + def register_handler( + self, command_type: type, handler: Callable[[Any], Any] + ) -> None: + """Register a handler for a specific command type. + + Args: + command_type: The command class this handler handles + handler: A callable that accepts a command and returns a result + """ + self._handlers[command_type] = handler + + def dispatch(self, command: Command) -> Any: + """Dispatch a command to its registered handler. + + Args: + command: The command to dispatch + + Returns: + The result from the handler + + Raises: + ValueError: If no handler is registered for the command type + """ + command_type = type(command) + if command_type not in self._handlers: + raise ValueError( + f"No handler registered for command type: {command_type.__name__}" + ) + handler = self._handlers[command_type] + return handler(command) + + def has_handler(self, command_type: type) -> bool: + """Check if a handler is registered for a command type. + + Args: + command_type: The command class to check + + Returns: + True if a handler is registered, False otherwise + """ + return command_type in self._handlers + + +# Global command bus instance +_command_bus: CommandBus | None = None + + +def get_command_bus() -> CommandBus: + """Get the global command bus instance. + + Returns: + The global CommandBus instance + """ + global _command_bus + if _command_bus is None: + _command_bus = CommandBus() + return _command_bus + + +def set_command_bus(bus: CommandBus) -> None: + """Set the global command bus instance. + + This is primarily used for testing to inject a mock or test bus. + + Args: + bus: The CommandBus instance to use globally + """ + global _command_bus + _command_bus = bus diff --git a/src/cleveragents/cli/command_registry.py b/src/cleveragents/cli/command_registry.py new file mode 100644 index 000000000..bdaaed0b3 --- /dev/null +++ b/src/cleveragents/cli/command_registry.py @@ -0,0 +1,39 @@ +"""Command registry for initializing the command bus with handlers. + +This module provides utilities for registering command handlers with the +command bus. It serves as the integration point between the CLI layer +and the application services layer. +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cleveragents.cli.command_bus import CommandBus + + +def register_command_handlers(bus: "CommandBus") -> None: + """Register all command handlers with the command bus. + + This function is called during application initialization to set up + the command bus with handlers for all supported commands. Handlers + are imported from the application services layer. + + Args: + bus: The CommandBus instance to register handlers with + """ + + # Register handlers for plan commands + # These handlers wrap service calls and are registered with the bus + def handle_plan_command(command: object) -> object: + """Handle plan-related commands.""" + # This will be implemented based on specific command types + return None + + def handle_project_command(command: object) -> object: + """Handle project-related commands.""" + # This will be implemented based on specific command types + return None + + # Register handlers + # bus.register_handler(PlanCommand, handle_plan_command) + # bus.register_handler(ProjectCommand, handle_project_command)