"""Step definitions for config_project_scope.feature.""" from __future__ import annotations import json 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 behave.runner import Context from typer.testing import CliRunner from cleveragents.application.services.config_service import ConfigService from cleveragents.cli.commands import config as config_mod from cleveragents.cli.commands.config import app as config_app _runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _get_service(context: Context) -> ConfigService: """Return the test-scoped ConfigService instance.""" svc: ConfigService = context._ps_config_service return svc def _cleanup(context: Context) -> None: """Clean up temp dir.""" tmpdir: str | None = getattr(context, "_ps_config_tmpdir", None) if tmpdir and os.path.isdir(tmpdir): shutil.rmtree(tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a clean project-scoped config environment") def step_clean_project_scoped_env(context: Context) -> None: """Set up an isolated temp config directory for project-scoped tests.""" tmpdir = tempfile.mkdtemp() context._ps_config_tmpdir = tmpdir tmp_path = Path(tmpdir) context._ps_config_service = ConfigService( config_dir=tmp_path, config_path=tmp_path / "config.toml", ) context._ps_config_dir = tmp_path context._ps_config_path = tmp_path / "config.toml" if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _cleanup(context)) # --------------------------------------------------------------------------- # Given # --------------------------------------------------------------------------- @given('a global config value "{key}" = "{value}"') def step_set_global_config(context: Context, key: str, value: str) -> None: svc = _get_service(context) data = svc.read_config() data[key] = value svc.write_config(data) @given('a project-scoped config value "{key}" = "{value}" for project "{project}"') def step_set_project_config( context: Context, key: str, value: str, project: str ) -> None: svc = _get_service(context) data = svc.read_config() proj_section: dict[str, Any] = data.get("project", {}) if not isinstance(proj_section, dict): proj_section = {} proj_overrides: dict[str, Any] = proj_section.get(project, {}) if not isinstance(proj_overrides, dict): proj_overrides = {} proj_overrides[key] = value proj_section[project] = proj_overrides data["project"] = proj_section svc.write_config(data) # --------------------------------------------------------------------------- # When # --------------------------------------------------------------------------- @when('I set config "{key}" to "{value}" for project "{project}"') def step_set_config_for_project( context: Context, key: str, value: str, project: str ) -> None: svc = _get_service(context) data = svc.read_config() proj_section: dict[str, Any] = data.get("project", {}) if not isinstance(proj_section, dict): proj_section = {} proj_overrides: dict[str, Any] = proj_section.get(project, {}) if not isinstance(proj_overrides, dict): proj_overrides = {} proj_overrides[key] = value proj_section[project] = proj_overrides data["project"] = proj_section svc.write_config(data) @when('I get config "{key}" for project "{project}"') def step_get_config_for_project(context: Context, key: str, project: str) -> None: svc = _get_service(context) context.resolved = svc.resolve(key, project_name=project) @when('I list config for project "{project}"') def step_list_config_for_project(context: Context, project: str) -> None: svc = _get_service(context) context.project_overrides = svc.get_project_overrides(project) @when('I attempt to set non-scopable key "{key}" to "{value}" for project "{project}"') def step_attempt_set_non_scopable( context: Context, key: str, value: str, project: str ) -> None: svc = _get_service(context) try: svc.set_project_value(project, key, value) context.raised_error = None except (ValueError, TypeError) as exc: context.raised_error = exc @when('I run config set "{key}" "{value}" with project "{project}" via CLI') def step_run_cli_set_with_project( context: Context, key: str, value: str, project: str ) -> None: tmp_dir: Path = context._ps_config_dir tmp_path: Path = context._ps_config_path with ( patch.object(config_mod, "_CONFIG_DIR", tmp_dir), patch.object(config_mod, "_CONFIG_PATH", tmp_path), ): context.result = _runner.invoke( config_app, ["set", key, value, "--project", project] ) @when('I run config get "{key}" with project "{project}" via CLI') def step_run_cli_get_with_project(context: Context, key: str, project: str) -> None: tmp_dir: Path = context._ps_config_dir tmp_path: Path = context._ps_config_path with ( patch.object(config_mod, "_CONFIG_DIR", tmp_dir), patch.object(config_mod, "_CONFIG_PATH", tmp_path), ): context.result = _runner.invoke( config_app, ["get", key, "--project", project, "--format", "json"] ) @when('I run config list with project "{project}" via CLI') def step_run_cli_list_with_project(context: Context, project: str) -> None: tmp_dir: Path = context._ps_config_dir tmp_path: Path = context._ps_config_path with ( patch.object(config_mod, "_CONFIG_DIR", tmp_dir), patch.object(config_mod, "_CONFIG_PATH", tmp_path), ): context.result = _runner.invoke( config_app, ["list", "--project", project, "--format", "json"] ) @when("I attempt to get overrides for empty project name") def step_attempt_empty_project_name(context: Context) -> None: svc = _get_service(context) try: svc.get_project_overrides("") context.raised_error = None except ValueError as exc: context.raised_error = exc @when('I use service set_project_value for "{project}" key "{key}" value "{value}"') def step_use_service_set_project_value( context: Context, project: str, key: str, value: str ) -> None: svc = _get_service(context) svc.set_project_value(project, key, value) @when('I get all project overrides for "{project}"') def step_get_all_project_overrides(context: Context, project: str) -> None: svc = _get_service(context) context.project_overrides = svc.get_project_overrides(project) # --------------------------------------------------------------------------- # Then # --------------------------------------------------------------------------- @then('the project-scoped config for "{project}" should contain "{key}" = "{value}"') def step_project_config_contains( context: Context, project: str, key: str, value: str ) -> None: svc = _get_service(context) overrides = svc.get_project_overrides(project) assert key in overrides, ( f"Expected key '{key}' in project overrides, got: {list(overrides.keys())}" ) assert str(overrides[key]) == value, f"Expected '{value}', got '{overrides[key]}'" @then('the project-resolved value should be "{value}"') def step_project_resolved_value(context: Context, value: str) -> None: assert str(context.resolved.value) == value, ( f"Expected '{value}', got '{context.resolved.value}'" ) @then("the output should show {count:d} project-scoped overrides") def step_project_override_count(context: Context, count: int) -> None: overrides: dict[str, Any] = context.project_overrides assert len(overrides) == count, ( f"Expected {count} overrides, got {len(overrides)}: {list(overrides.keys())}" ) @then("the project set should fail with a scopable error") def step_project_set_fail_scopable(context: Context) -> None: assert context.raised_error is not None, "Expected ValueError but none was raised" assert "not project-scopable" in str(context.raised_error), ( f"Expected 'not project-scopable' in: {context.raised_error}" ) @then("the CLI config set should succeed") def step_cli_set_succeed(context: Context) -> None: assert context.result.exit_code == 0, ( f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" ) @then('the CLI output should mention project scope "{project}"') def step_cli_output_mentions_project(context: Context, project: str) -> None: output: str = context.result.output assert project in output, f"Expected '{project}' in output: {output}" @then("the CLI config get should succeed") def step_cli_get_succeed(context: Context) -> None: assert context.result.exit_code == 0, ( f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" ) @then("the CLI config list should succeed") def step_cli_list_succeed(context: Context) -> None: assert context.result.exit_code == 0, ( f"Expected exit 0, got {context.result.exit_code}: {context.result.output}" ) @then("the CLI list output should show exactly {count:d} entries") def step_cli_list_exact_count(context: Context, count: int) -> None: output: str = context.result.output data: list[dict[str, Any]] = json.loads(output) assert len(data) == count, f"Expected {count} entries, got {len(data)}: {data}" @then("the CLI config list should show no overrides message") def step_cli_list_no_overrides(context: Context) -> None: output: str = context.result.output # When no overrides and using JSON format, an empty or message output # The command should still succeed (exit 0) but indicate nothing found assert context.result.exit_code == 0 or "No project-scoped overrides" in output, ( f"Expected no-overrides output, got: {output}" ) @then("the empty project name should raise ValueError") def step_empty_project_raises(context: Context) -> None: assert context.raised_error is not None, "Expected ValueError but none was raised" assert isinstance(context.raised_error, ValueError), ( f"Expected ValueError, got {type(context.raised_error).__name__}" ) @then("the project overrides should have {count:d} keys") def step_project_overrides_key_count(context: Context, count: int) -> None: overrides: dict[str, Any] = context.project_overrides assert len(overrides) == count, ( f"Expected {count} keys, got {len(overrides)}: {list(overrides.keys())}" ) @then('the project overrides should not contain "{key}"') def step_project_overrides_not_contain(context: Context, key: str) -> None: overrides: dict[str, Any] = context.project_overrides assert key not in overrides, ( f"Expected '{key}' NOT in project overrides, but it was present" )