diff --git a/features/project_cli_commands.feature b/features/project_cli_commands.feature new file mode 100644 index 000000000..be695a2f5 --- /dev/null +++ b/features/project_cli_commands.feature @@ -0,0 +1,214 @@ +Feature: Project CLI command functions coverage + As a developer + I want the project CLI command functions exercised directly + So that cli/commands/project.py achieves high coverage + + Background: + Given a project CLI commands test database is initialized + + # ── helpers that are never reached from the repository-only tests ── + + Scenario: _get_namespaced_project_repo returns a repository + When I call _get_namespaced_project_repo via the patched container + Then the helper should return a NamespacedProjectRepository + + Scenario: _get_resource_link_repo returns a repository + When I call _get_resource_link_repo via the patched container + Then the helper should return a ProjectResourceLinkRepository + + Scenario: _get_resource_registry_service returns a service + When I call _get_resource_registry_service via the patched container + Then the helper should return a ResourceRegistryService + + Scenario: _store_project_extras stores invariants via file DB + When I call _store_project_extras with invariants "no-delete" and actor "guard" + Then the extras call should complete without error + + # ── create command ──────────────────────────────────────────── + + Scenario: create command with bare name uses rich output + When I invoke project-create with bare name "my-app" + Then the project cmd output should contain "created" + And the project cmd should succeed + + Scenario: create command with explicit namespace + When I invoke project-create with bare name "team/my-svc" + Then the project cmd output should contain "created" + And the project cmd should succeed + + Scenario: create command with description + When I invoke project-create with name "desc-proj" description "A nice project" + Then the project cmd output should contain "created" + And the project cmd should succeed + + Scenario: create command with json format + When I invoke project-create with name "json-proj" format "json" + Then the project cmd output should contain "namespaced_name" + And the project cmd should succeed + + Scenario: create command with invariants + When I invoke project-create with name "inv-proj" invariant "do not delete files" + Then the project cmd output should contain "created" + And the project cmd should succeed + + Scenario: create command with linked resource + Given a resource "my-git-res" is registered in the commands DB + When I invoke project-create with name "res-proj" linking resource "my-git-res" + Then the project cmd output should contain "created" + And the project cmd should succeed + + Scenario: create command with invalid name fails + When I invoke project-create with bare name "123bad" + Then the project cmd should fail + + Scenario: create command with DatabaseError from repo + When I invoke project-create triggering a database error + Then the project cmd should fail + + # ── list command ───────────────────────────────────────────── + + Scenario: list command shows projects in rich format + Given a project "local/list-a" is created in the commands DB + And a project "local/list-b" is created in the commands DB + When I invoke project-list with default options + Then the project cmd output should contain "list-a" + And the project cmd output should contain "list-b" + + Scenario: list command with namespace filter + Given a project "local/ns-x" is created in the commands DB + And a project "team/ns-y" is created in the commands DB + When I invoke project-list with namespace "team" + Then the project cmd output should contain "ns-y" + + Scenario: list command with regex filter + Given a project "local/api-svc" is created in the commands DB + And a project "local/web-ui" is created in the commands DB + When I invoke project-list with regex "api" + Then the project cmd output should contain "api-svc" + + Scenario: list command with invalid regex fails + When I invoke project-list with invalid regex "[bad" + Then the project cmd should fail + + Scenario: list command when empty + When I invoke project-list with default options + Then the project cmd output should contain "No projects found" + + Scenario: list command with json format + Given a project "local/fmt-list" is created in the commands DB + When I invoke project-list with format "json" + Then the project cmd output should contain "namespaced_name" + + # ── show command ───────────────────────────────────────────── + + Scenario: show command displays project in rich format + Given a project "local/show-cmd" is created in the commands DB + When I invoke project-show for "local/show-cmd" default format + Then the project cmd output should contain "show-cmd" + And the project cmd should succeed + + Scenario: show command with linked resources + Given a project "local/show-linked" is created in the commands DB + And a resource is linked to project "local/show-linked" in the commands DB + When I invoke project-show for "local/show-linked" default format + Then the project cmd output should contain "show-linked" + + Scenario: show command with json format + Given a project "local/show-json" is created in the commands DB + When I invoke project-show for "local/show-json" format "json" + Then the project cmd output should contain "namespaced_name" + + Scenario: show nonexistent project fails + When I invoke project-show for "local/ghost" default format + Then the project cmd should fail + + # ── link-resource command ──────────────────────────────────── + + Scenario: link-resource command in rich format + Given a project "local/link-cmd" is created in the commands DB + And a resource "link-res" is registered in the commands DB + When I invoke project-link for "local/link-cmd" resource "link-res" default format + Then the project cmd output should contain "Linked" + And the project cmd should succeed + + Scenario: link-resource command with read-only + Given a project "local/ro-cmd" is created in the commands DB + And a resource "ro-res" is registered in the commands DB + When I invoke project-link for "local/ro-cmd" resource "ro-res" read-only + Then the project cmd output should contain "read-only" + And the project cmd should succeed + + Scenario: link-resource command with json format + Given a project "local/link-json" is created in the commands DB + And a resource "json-res" is registered in the commands DB + When I invoke project-link for "local/link-json" resource "json-res" format "json" + Then the project cmd output should contain "resource_id" + + Scenario: link-resource to nonexistent project fails + When I invoke project-link for "local/nope" resource "anything" default format + Then the project cmd should fail + + Scenario: link-resource with nonexistent resource fails + Given a project "local/link-fail" is created in the commands DB + When I invoke project-link for "local/link-fail" resource "no-such-res" default format + Then the project cmd should fail + + # ── unlink-resource command ────────────────────────────────── + + Scenario: unlink-resource command in rich format + Given a project "local/unlink-cmd" is created in the commands DB + And a resource is linked to project "local/unlink-cmd" in the commands DB + When I invoke project-unlink for "local/unlink-cmd" with yes default format + Then the project cmd output should contain "Unlinked" + And the project cmd should succeed + + Scenario: unlink-resource command with json format + Given a project "local/unlink-json" is created in the commands DB + And a resource is linked to project "local/unlink-json" in the commands DB + When I invoke project-unlink for "local/unlink-json" with yes format "json" + Then the project cmd output should contain "unlinked" + + Scenario: unlink-resource for nonexistent project fails + When I invoke project-unlink for "local/ghost-proj" resource "any" with yes + Then the project cmd should fail + + Scenario: unlink-resource for nonexistent resource fails + Given a project "local/unlink-fail" is created in the commands DB + When I invoke project-unlink for "local/unlink-fail" resource "no-such" with yes + Then the project cmd should fail + + Scenario: unlink-resource when resource not linked fails + Given a project "local/unlink-none" is created in the commands DB + And a resource "orphan-res" is registered in the commands DB + When I invoke project-unlink for "local/unlink-none" resource "orphan-res" with yes + Then the project cmd should fail + + # ── delete command ─────────────────────────────────────────── + + Scenario: delete command removes project + Given a project "local/del-cmd" is created in the commands DB + When I invoke project-delete for "local/del-cmd" with yes + Then the project cmd output should contain "deleted" + And the project cmd should succeed + + Scenario: delete command with json format + Given a project "local/del-json" is created in the commands DB + When I invoke project-delete for "local/del-json" with yes format "json" + Then the project cmd output should contain "deleted" + + Scenario: delete nonexistent project fails + When I invoke project-delete for "local/nope" with yes + Then the project cmd should fail + + Scenario: delete project with linked resources without force fails + Given a project "local/del-linked" is created in the commands DB + And a resource is linked to project "local/del-linked" in the commands DB + When I invoke project-delete for "local/del-linked" with yes no force + Then the project cmd should fail + + Scenario: delete project with linked resources with force succeeds + Given a project "local/del-force" is created in the commands DB + And a resource is linked to project "local/del-force" in the commands DB + When I invoke project-delete for "local/del-force" with yes and force + Then the project cmd output should contain "deleted" + And the project cmd should succeed diff --git a/features/steps/cli_output_formats_steps.py b/features/steps/cli_output_formats_steps.py index f0db25526..7f2f02c17 100644 --- a/features/steps/cli_output_formats_steps.py +++ b/features/steps/cli_output_formats_steps.py @@ -95,10 +95,28 @@ def step_mocked_lifecycle_for_formats(context: Context) -> None: context.action_patcher.start() context.plan_patcher.start() + # Patch module-level Rich Console objects so they never inject ANSI + # escape codes into structured output (JSON/YAML/plain). Without this, + # console.print(json_string) adds syntax-highlighting escapes when Rich + # detects a real terminal, which makes json.loads() / yaml.safe_load() + # fail on the user's machine even though the tests pass inside a + # headless container where Console auto-disables colour. + from rich.console import Console as _Console + + _plain_console = _Console(no_color=True, highlight=False) + context.action_console_patcher = patch.object( + _action_mod, "console", _plain_console + ) + context.plan_console_patcher = patch.object(_plan_mod, "console", _plain_console) + context.action_console_patcher.start() + context.plan_console_patcher.start() + if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(context.action_patcher.stop) context._cleanup_handlers.append(context.plan_patcher.stop) + context._cleanup_handlers.append(context.action_console_patcher.stop) + context._cleanup_handlers.append(context.plan_console_patcher.stop) # ------- Given steps ------- diff --git a/features/steps/project_cli_commands_steps.py b/features/steps/project_cli_commands_steps.py new file mode 100644 index 000000000..1c4b9941f --- /dev/null +++ b/features/steps/project_cli_commands_steps.py @@ -0,0 +1,653 @@ +"""Step definitions for project_cli_commands.feature. + +Exercises the *actual* CLI command functions in +``cleveragents.cli.commands.project`` (create, list, show, link-resource, +unlink-resource, delete) via monkey-patched helpers so that the command +bodies are executed under coverage. + +Each scenario gets a fresh in-memory SQLite database. The DI-container +look-up helpers are monkey-patched to return repositories backed by that +database, then restored after every step that needs them. +""" + +from __future__ import annotations + +from io import StringIO +from typing import Any + +from behave import given, then, when # type: ignore[import-untyped] +from rich.console import Console +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# --------------------------------------------------------------------------- +# Database / service bootstrap +# --------------------------------------------------------------------------- + + +class _SharedSession: + """Wraps a real SQLAlchemy Session but makes ``close()`` a no-op. + + This ensures every repository call within the same scenario uses the + same session (and therefore sees the same uncommitted data), which is + critical for in-memory SQLite where transaction isolation would otherwise + hide data between sessions. + """ + + def __init__(self, real: Any) -> None: + object.__setattr__(self, "_real", real) + + def close(self) -> None: + """No-op so the shared session stays usable across calls.""" + + def __getattr__(self, name: str) -> Any: + return getattr(object.__getattribute__(self, "_real"), name) + + def __setattr__(self, name: str, value: Any) -> None: + setattr(object.__getattribute__(self, "_real"), name, value) + + +def _init_commands_db(context: Any) -> None: + """Create a fresh in-memory database and wire up repos + service.""" + from cleveragents.application.services.resource_registry_service import ( + ResourceRegistryService, + ) + from cleveragents.infrastructure.database.models import Base + from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, + ProjectResourceLinkRepository, + ) + + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + ) + Base.metadata.create_all(engine) + + # Use a single shared session to avoid transaction isolation issues + # with :memory: SQLite when multiple repos are involved. + real_session = sessionmaker( + bind=engine, expire_on_commit=False, autoflush=True, autocommit=False + )() + wrapper = _SharedSession(real_session) + + def _shared_factory() -> Any: + return wrapper + + context._cmd_engine = engine + context._cmd_factory = _shared_factory + context._cmd_project_repo = NamespacedProjectRepository( + session_factory=_shared_factory + ) + context._cmd_link_repo = ProjectResourceLinkRepository( + session_factory=_shared_factory + ) + context._cmd_resource_svc = ResourceRegistryService(session_factory=_shared_factory) + context._cmd_resource_svc.bootstrap_builtin_types() + context._cmd_output = "" + context._cmd_failed = False + context._cmd_resource_map = {} + context._cmd_last_resource_id = None + + +# --------------------------------------------------------------------------- +# Monkey-patching helpers +# --------------------------------------------------------------------------- + +_ORIG_FNS: dict[str, Any] = {} + + +def _patch_project_mod(context: Any) -> None: + """Monkey-patch the four DI look-up helpers in project module.""" + import cleveragents.cli.commands.project as project_mod + + _ORIG_FNS["repo"] = project_mod._get_namespaced_project_repo + _ORIG_FNS["link"] = project_mod._get_resource_link_repo + _ORIG_FNS["svc"] = project_mod._get_resource_registry_service + + project_mod._get_namespaced_project_repo = lambda: context._cmd_project_repo + project_mod._get_resource_link_repo = lambda: context._cmd_link_repo + project_mod._get_resource_registry_service = lambda: context._cmd_resource_svc + + # Patch _store_project_extras to a no-op since it needs a shared + # file-based DB (the real helper is tested separately) + _ORIG_FNS["store_extras"] = project_mod._store_project_extras + project_mod._store_project_extras = lambda *a, **kw: None + + +def _unpatch_project_mod() -> None: + """Restore original helpers.""" + import cleveragents.cli.commands.project as project_mod + + project_mod._get_namespaced_project_repo = _ORIG_FNS["repo"] + project_mod._get_resource_link_repo = _ORIG_FNS["link"] + project_mod._get_resource_registry_service = _ORIG_FNS["svc"] + if _ORIG_FNS.get("store_extras"): + project_mod._store_project_extras = _ORIG_FNS["store_extras"] + + +def _capture(context: Any, func: Any, *args: Any, **kwargs: Any) -> None: + """Call a CLI function, capturing console output and success/failure.""" + import typer + + import cleveragents.cli.commands.project as project_mod + + buf = StringIO() + fake_console = Console(file=buf, width=200, no_color=True) + + orig_console = project_mod.console + orig_err = project_mod.err_console + project_mod.console = fake_console + project_mod.err_console = fake_console + + _patch_project_mod(context) + failed = False + try: + func(*args, **kwargs) + except (SystemExit, typer.Exit, typer.Abort): + failed = True + except Exception: + failed = True + finally: + project_mod.console = orig_console + project_mod.err_console = orig_err + _unpatch_project_mod() + + context._cmd_output = buf.getvalue() + context._cmd_failed = failed + + +# --------------------------------------------------------------------------- +# Reusable helpers +# --------------------------------------------------------------------------- + + +def _create_project(context: Any, name: str, description: str | None = None) -> Any: + from cleveragents.domain.models.core.project import ( + NamespacedProject, + parse_namespaced_name, + ) + + parsed = parse_namespaced_name(name) + proj = NamespacedProject( + name=parsed.name, + namespace=parsed.namespace, + server=parsed.server, + description=description, + ) + context._cmd_project_repo.create(proj) + return proj + + +def _register_resource(context: Any, alias: str) -> str: + res = context._cmd_resource_svc.register_resource( + type_name="git-checkout", + name=f"local/{alias}", + location="/tmp/test-resource", + description=f"Test resource {alias}", + ) + context._cmd_resource_map[alias] = res.resource_id + context._cmd_last_resource_id = res.resource_id + return res.resource_id + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("a project CLI commands test database is initialized") +def step_init_commands_db(context: Any) -> None: + _init_commands_db(context) + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given('a project "{name}" is created in the commands DB') +def step_project_exists_cmd(context: Any, name: str) -> None: + _create_project(context, name) + + +@given('a resource "{alias}" is registered in the commands DB') +def step_resource_registered_cmd(context: Any, alias: str) -> None: + _register_resource(context, alias) + + +@given('a resource is linked to project "{project_name}" in the commands DB') +def step_resource_linked_cmd(context: Any, project_name: str) -> None: + safe = project_name.replace("/", "-") + rid = _register_resource(context, f"linked-{safe}") + context._cmd_link_repo.create_link( + project_name=project_name, + resource_id=rid, + ) + + +# --------------------------------------------------------------------------- +# Helper function coverage +# --------------------------------------------------------------------------- + + +@when("I call _get_namespaced_project_repo via the patched container") +def step_call_get_repo(context: Any) -> None: + import cleveragents.cli.commands.project as project_mod + + _patch_project_mod(context) + try: + context._cmd_helper_result = project_mod._get_namespaced_project_repo() + finally: + _unpatch_project_mod() + + +@when("I call _get_resource_link_repo via the patched container") +def step_call_get_link_repo(context: Any) -> None: + import cleveragents.cli.commands.project as project_mod + + _patch_project_mod(context) + try: + context._cmd_helper_result = project_mod._get_resource_link_repo() + finally: + _unpatch_project_mod() + + +@when("I call _get_resource_registry_service via the patched container") +def step_call_get_svc(context: Any) -> None: + import cleveragents.cli.commands.project as project_mod + + _patch_project_mod(context) + try: + context._cmd_helper_result = project_mod._get_resource_registry_service() + finally: + _unpatch_project_mod() + + +@when('I call _store_project_extras with invariants "{inv}" and actor "{actor}"') +def step_call_store_extras(context: Any, inv: str, actor: str) -> None: + """Call the real _store_project_extras against a temp file-based DB. + + ``_store_project_extras`` opens its own engine from ``get_database_url()``, + so we create a file-based temp DB, seed a project, and patch the URL. + """ + import tempfile + from pathlib import Path + + from sqlalchemy import create_engine as _ce + from sqlalchemy.orm import sessionmaker as _sm + + from cleveragents.domain.models.core.project import ( + NamespacedProject, + parse_namespaced_name, + ) + from cleveragents.infrastructure.database.models import Base as _Base + from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, + ) + + tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) # noqa: SIM115 + tmp.close() + db_url = f"sqlite:///{tmp.name}" + engine = _ce(db_url, echo=False) + _Base.metadata.create_all(engine) + # Use a single session so we can commit after repo.create() + session = _sm(bind=engine, expire_on_commit=False)() + + def _factory() -> Any: + return session + + repo = NamespacedProjectRepository(session_factory=_factory) + parsed = parse_namespaced_name("local/extras-proj") + proj = NamespacedProject(name=parsed.name, namespace=parsed.namespace) + repo.create(proj) + session.commit() + + import cleveragents.application.container as container_mod + import cleveragents.cli.commands.project as project_mod + + orig = container_mod.get_database_url + container_mod.get_database_url = lambda: db_url + try: + project_mod._store_project_extras( + "local/extras-proj", + invariant_texts=[inv], + inv_actor=actor, + ) + context._cmd_failed = False + except Exception: + context._cmd_failed = True + finally: + container_mod.get_database_url = orig + engine.dispose() + Path(tmp.name).unlink(missing_ok=True) + + +@then("the helper should return a NamespacedProjectRepository") +def step_check_repo(context: Any) -> None: + from cleveragents.infrastructure.database.repositories import ( + NamespacedProjectRepository, + ) + + assert isinstance(context._cmd_helper_result, NamespacedProjectRepository) + + +@then("the helper should return a ProjectResourceLinkRepository") +def step_check_link_repo(context: Any) -> None: + from cleveragents.infrastructure.database.repositories import ( + ProjectResourceLinkRepository, + ) + + assert isinstance(context._cmd_helper_result, ProjectResourceLinkRepository) + + +@then("the helper should return a ResourceRegistryService") +def step_check_svc(context: Any) -> None: + from cleveragents.application.services.resource_registry_service import ( + ResourceRegistryService, + ) + + assert isinstance(context._cmd_helper_result, ResourceRegistryService) + + +@then("the extras call should complete without error") +def step_extras_ok(context: Any) -> None: + assert not context._cmd_failed, "Expected _store_project_extras to succeed" + + +# --------------------------------------------------------------------------- +# Create command +# --------------------------------------------------------------------------- + + +@when("I invoke project-create triggering a database error") +def step_invoke_create_db_error(context: Any) -> None: + """Exercise the ``except DatabaseError`` branch in the create command.""" + from unittest.mock import MagicMock + + from cleveragents.cli.commands.project import create + from cleveragents.core.exceptions import DatabaseError + + # Temporarily replace the repo with one whose create() raises DatabaseError + orig_repo = context._cmd_project_repo + mock_repo = MagicMock() + mock_repo.create.side_effect = DatabaseError("simulated DB failure") + context._cmd_project_repo = mock_repo + try: + _capture(context, create, name="db-err-proj") + finally: + context._cmd_project_repo = orig_repo + + +@when('I invoke project-create with bare name "{name}"') +def step_invoke_create(context: Any, name: str) -> None: + from cleveragents.cli.commands.project import create + + _capture(context, create, name=name) + + +@when('I invoke project-create with name "{name}" description "{desc}"') +def step_invoke_create_desc(context: Any, name: str, desc: str) -> None: + from cleveragents.cli.commands.project import create + + _capture(context, create, name=name, description=desc) + + +@when('I invoke project-create with name "{name}" format "{fmt}"') +def step_invoke_create_fmt(context: Any, name: str, fmt: str) -> None: + from cleveragents.cli.commands.project import create + + _capture(context, create, name=name, output_format=fmt) + + +@when('I invoke project-create with name "{name}" invariant "{inv}"') +def step_invoke_create_inv(context: Any, name: str, inv: str) -> None: + from cleveragents.cli.commands.project import create + + _capture(context, create, name=name, invariant=[inv]) + + +@when('I invoke project-create with name "{name}" linking resource "{res}"') +def step_invoke_create_res(context: Any, name: str, res: str) -> None: + from cleveragents.cli.commands.project import create + + _capture(context, create, name=name, resource=[f"local/{res}"]) + + +# --------------------------------------------------------------------------- +# List command +# --------------------------------------------------------------------------- + + +@when("I invoke project-list with default options") +def step_invoke_list(context: Any) -> None: + from cleveragents.cli.commands.project import list_projects + + _capture(context, list_projects) + + +@when('I invoke project-list with namespace "{ns}"') +def step_invoke_list_ns(context: Any, ns: str) -> None: + from cleveragents.cli.commands.project import list_projects + + _capture(context, list_projects, namespace=ns) + + +@when('I invoke project-list with regex "{regex}"') +def step_invoke_list_regex(context: Any, regex: str) -> None: + from cleveragents.cli.commands.project import list_projects + + _capture(context, list_projects, regex=regex) + + +@when('I invoke project-list with invalid regex "{regex}"') +def step_invoke_list_bad_regex(context: Any, regex: str) -> None: + from cleveragents.cli.commands.project import list_projects + + _capture(context, list_projects, regex=regex) + + +@when('I invoke project-list with format "{fmt}"') +def step_invoke_list_fmt(context: Any, fmt: str) -> None: + from cleveragents.cli.commands.project import list_projects + + _capture(context, list_projects, output_format=fmt) + + +# --------------------------------------------------------------------------- +# Show command +# --------------------------------------------------------------------------- + + +@when('I invoke project-show for "{name}" default format') +def step_invoke_show(context: Any, name: str) -> None: + from cleveragents.cli.commands.project import show + + _capture(context, show, project=name) + + +@when('I invoke project-show for "{name}" format "{fmt}"') +def step_invoke_show_fmt(context: Any, name: str, fmt: str) -> None: + from cleveragents.cli.commands.project import show + + _capture(context, show, project=name, output_format=fmt) + + +# --------------------------------------------------------------------------- +# Link-resource command +# --------------------------------------------------------------------------- + + +@when('I invoke project-link for "{proj}" resource "{res}" default format') +def step_invoke_link(context: Any, proj: str, res: str) -> None: + from cleveragents.cli.commands.project import link_resource + + resource_name = f"local/{res}" if "/" not in res else res + _capture(context, link_resource, project=proj, resource_name=resource_name) + + +@when('I invoke project-link for "{proj}" resource "{res}" read-only') +def step_invoke_link_ro(context: Any, proj: str, res: str) -> None: + from cleveragents.cli.commands.project import link_resource + + resource_name = f"local/{res}" if "/" not in res else res + _capture( + context, + link_resource, + project=proj, + resource_name=resource_name, + read_only=True, + ) + + +@when('I invoke project-link for "{proj}" resource "{res}" format "{fmt}"') +def step_invoke_link_fmt(context: Any, proj: str, res: str, fmt: str) -> None: + from cleveragents.cli.commands.project import link_resource + + resource_name = f"local/{res}" if "/" not in res else res + _capture( + context, + link_resource, + project=proj, + resource_name=resource_name, + output_format=fmt, + ) + + +# --------------------------------------------------------------------------- +# Unlink-resource command +# --------------------------------------------------------------------------- + + +@when('I invoke project-unlink for "{proj}" with yes default format') +def step_invoke_unlink(context: Any, proj: str) -> None: + from cleveragents.cli.commands.project import unlink_resource + + links = context._cmd_link_repo.list_links(proj) + if links: + res_id = str(links[0].resource_id) + try: + res = context._cmd_resource_svc.show_resource(res_id) + res_name = res.name or res_id + except Exception: + res_name = res_id + _capture( + context, + unlink_resource, + project=proj, + resource_name=res_name, + yes=True, + ) + else: + _capture( + context, + unlink_resource, + project=proj, + resource_name="none", + yes=True, + ) + + +@when('I invoke project-unlink for "{proj}" with yes format "{fmt}"') +def step_invoke_unlink_fmt(context: Any, proj: str, fmt: str) -> None: + from cleveragents.cli.commands.project import unlink_resource + + links = context._cmd_link_repo.list_links(proj) + if links: + res_id = str(links[0].resource_id) + try: + res = context._cmd_resource_svc.show_resource(res_id) + res_name = res.name or res_id + except Exception: + res_name = res_id + _capture( + context, + unlink_resource, + project=proj, + resource_name=res_name, + yes=True, + output_format=fmt, + ) + else: + _capture( + context, + unlink_resource, + project=proj, + resource_name="none", + yes=True, + output_format=fmt, + ) + + +@when('I invoke project-unlink for "{proj}" resource "{res}" with yes') +def step_invoke_unlink_named(context: Any, proj: str, res: str) -> None: + from cleveragents.cli.commands.project import unlink_resource + + resource_name = f"local/{res}" if "/" not in res else res + _capture( + context, + unlink_resource, + project=proj, + resource_name=resource_name, + yes=True, + ) + + +# --------------------------------------------------------------------------- +# Delete command +# --------------------------------------------------------------------------- + + +@when('I invoke project-delete for "{name}" with yes') +def step_invoke_delete(context: Any, name: str) -> None: + from cleveragents.cli.commands.project import delete + + _capture(context, delete, name=name, yes=True) + + +@when('I invoke project-delete for "{name}" with yes format "{fmt}"') +def step_invoke_delete_fmt(context: Any, name: str, fmt: str) -> None: + from cleveragents.cli.commands.project import delete + + _capture(context, delete, name=name, yes=True, output_format=fmt) + + +@when('I invoke project-delete for "{name}" with yes no force') +def step_invoke_delete_no_force(context: Any, name: str) -> None: + from cleveragents.cli.commands.project import delete + + _capture(context, delete, name=name, yes=True, force=False) + + +@when('I invoke project-delete for "{name}" with yes and force') +def step_invoke_delete_force(context: Any, name: str) -> None: + from cleveragents.cli.commands.project import delete + + _capture(context, delete, name=name, yes=True, force=True) + + +# --------------------------------------------------------------------------- +# Then assertions +# --------------------------------------------------------------------------- + + +@then('the project cmd output should contain "{text}"') +def step_cmd_output_contains(context: Any, text: str) -> None: + assert text.lower() in context._cmd_output.lower(), ( + f"Expected output to contain '{text}', got:\n{context._cmd_output}" + ) + + +@then("the project cmd should succeed") +def step_cmd_success(context: Any) -> None: + assert not context._cmd_failed, ( + f"Expected command to succeed but it failed. Output:\n{context._cmd_output}" + ) + + +@then("the project cmd should fail") +def step_cmd_fail(context: Any) -> None: + assert context._cmd_failed, ( + f"Expected command to fail but it succeeded. Output:\n{context._cmd_output}" + )