"""Step definitions for config_cli_uncovered_branches.feature. Covers missed lines/branches in cleveragents/cli/commands/config.py: _settings_defaults registry defaults path _validate_key empty key _write_config_file existing-file merge _resolve_source env-var path config_set non-rich format, --project flag, type coercion config_get non-rich + Path serialisation, --verbose flag config_list empty result, invalid regex, secret masking """ from __future__ import annotations import contextlib import json import os import shutil import tempfile from pathlib import Path from typing import Any from unittest.mock import patch import typer from behave import given, then, when from behave.runner import Context from typer.testing import CliRunner from cleveragents.application.services.config_service import ( _REGISTRY, ConfigEntry, ConfigLevel, ResolvedValue, ) from cleveragents.cli.commands import config as config_mod from cleveragents.cli.commands.config import ( _resolve_source, _settings_defaults, _validate_key, _write_config_file, ) from cleveragents.cli.commands.config import ( app as config_app, ) _runner = CliRunner() _ENVELOPE_KEYS = {"command", "status", "exit_code", "data", "timing", "messages"} def _unwrap_envelope(parsed: Any) -> Any: """Return the ``data`` field if *parsed* is a spec envelope, else *parsed* as-is.""" if isinstance(parsed, dict) and _ENVELOPE_KEYS.issubset(parsed.keys()): return parsed["data"] return parsed # --------------------------------------------------------------------------- # Helpers - isolated temp dir patching # --------------------------------------------------------------------------- def _patch_temp_config(context: Context) -> None: """Redirect _CONFIG_DIR / _CONFIG_PATH to a fresh temp directory.""" if not hasattr(context, "_cfg_branch_tmpdir"): context._cfg_branch_tmpdir = tempfile.mkdtemp(prefix="cfg_branch_") tmp = Path(context._cfg_branch_tmpdir) context._cfg_branch_dir_patch = patch.object(config_mod, "_CONFIG_DIR", tmp) context._cfg_branch_path_patch = patch.object( config_mod, "_CONFIG_PATH", tmp / "config.toml" ) context._cfg_branch_dir_patch.start() context._cfg_branch_path_patch.start() if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers: list[Any] = [] context._cleanup_handlers.append(lambda: _stop_temp_config(context)) def _stop_temp_config(context: Context) -> None: for attr in ("_cfg_branch_dir_patch", "_cfg_branch_path_patch"): patcher = getattr(context, attr, None) if patcher is not None: with contextlib.suppress(RuntimeError): patcher.stop() tmpdir = getattr(context, "_cfg_branch_tmpdir", None) if tmpdir and os.path.isdir(tmpdir): shutil.rmtree(tmpdir, ignore_errors=True) # =================================================================== # _settings_defaults - returns defaults from registry # =================================================================== @when("I config cli branch call _settings_defaults") def step_call_settings_defaults(context: Context) -> None: context._cfg_branch_defaults_real = _settings_defaults() @then("the config cli branch defaults should contain registry default values") def step_defaults_registry_values(context: Context) -> None: defaults = context._cfg_branch_defaults_real # core.log.level should have default "FATAL" assert "core.log.level" in defaults, f"core.log.level missing: {defaults.keys()}" assert defaults["core.log.level"] == "FATAL", ( f"Expected 'FATAL', got {defaults['core.log.level']}" ) # plan.concurrency should have default 4 assert "plan.concurrency" in defaults, ( f"plan.concurrency missing: {defaults.keys()}" ) assert defaults["plan.concurrency"] == 4, ( f"Expected 4, got {defaults['plan.concurrency']}" ) # core.log.file-enabled should have default True assert "core.log.file-enabled" in defaults, ( f"core.log.file-enabled missing: {defaults.keys()}" ) assert defaults["core.log.file-enabled"] is True, ( f"Expected True, got {defaults['core.log.file-enabled']}" ) # =================================================================== # _settings_defaults - None default entry (mocked) # =================================================================== @given("a config cli branch mocked registry entry with None default") def step_mock_registry_none_default(context: Context) -> None: """Add a temporary registry entry with None as the default value.""" context._cfg_branch_mock_key = "_test_none_default_key" context._cfg_branch_orig_entry = _REGISTRY.get(context._cfg_branch_mock_key) _REGISTRY[context._cfg_branch_mock_key] = ConfigEntry( key=context._cfg_branch_mock_key, python_type=str, default=None, env_var="CLEVERAGENTS_TEST_NONE", project_scopable=False, description="Test entry with None default", section="test", ) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _cleanup_mock_registry(context)) def _cleanup_mock_registry(context: Context) -> None: key = getattr(context, "_cfg_branch_mock_key", None) if key and key in _REGISTRY: orig = getattr(context, "_cfg_branch_orig_entry", None) if orig is not None: _REGISTRY[key] = orig else: del _REGISTRY[key] @then("the config cli branch defaults should contain None for the mocked key") def step_defaults_none_value(context: Context) -> None: defaults = context._cfg_branch_defaults_real key = context._cfg_branch_mock_key assert key in defaults, f"{key} missing: {list(defaults.keys())[:10]}" assert defaults[key] is None, f"Expected None, got {defaults[key]}" # =================================================================== # _validate_key - empty key (L116-120) # =================================================================== @when("I config cli branch call _validate_key with an empty key") def step_call_validate_key_empty(context: Context) -> None: context._cfg_branch_validate_exc = None try: _validate_key("") except typer.BadParameter as exc: context._cfg_branch_validate_exc = exc @then("the config cli branch call should raise BadParameter") def step_validate_key_bad_param(context: Context) -> None: assert context._cfg_branch_validate_exc is not None, ( "Expected typer.BadParameter but no exception was raised" ) assert "empty" in str(context._cfg_branch_validate_exc).lower(), ( f"Expected 'empty' in message: {context._cfg_branch_validate_exc}" ) # =================================================================== # _write_config_file - existing file merge # =================================================================== @given("a config cli branch temp config directory") def step_temp_config_dir(context: Context) -> None: _patch_temp_config(context) @given('a config cli branch existing config file with key "{key}" set to "{value}"') def step_existing_config_file(context: Context, key: str, value: str) -> None: import tomlkit config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment] config_path.parent.mkdir(parents=True, exist_ok=True) doc = tomlkit.document() doc[key] = value with open(config_path, "w") as fh: tomlkit.dump(doc, fh) @when('I config cli branch write config with key "{key}" set to "{value}"') def step_write_config_merge(context: Context, key: str, value: str) -> None: coerced: Any = value with contextlib.suppress(ValueError): coerced = int(value) _write_config_file({key: coerced}) @then("the config cli branch config file should contain both keys") def step_config_file_both_keys(context: Context) -> None: import tomllib config_path: Path = config_mod._CONFIG_PATH # type: ignore[assignment] with open(config_path, "rb") as fh: data = tomllib.load(fh) assert "core.log.level" in data, f"core.log.level missing: {data}" assert "plan.concurrency" in data, f"plan.concurrency missing: {data}" assert data["core.log.level"] == "DEBUG", ( f"core.log.level: {data['core.log.level']}" ) assert data["plan.concurrency"] == 4, ( f"plan.concurrency: {data['plan.concurrency']}" ) # =================================================================== # _resolve_source - env var path # =================================================================== @given('the config cli branch env var "{var}" is set to "{value}"') def step_set_env_var(context: Context, var: str, value: str) -> None: os.environ[var] = value if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: os.environ.pop(var, None)) @when('I config cli branch call _resolve_source for "{key}"') def step_call_resolve_source(context: Context, key: str) -> None: context._cfg_branch_source = _resolve_source(key) @then('the config cli branch source should be "{expected}"') def step_source_equals(context: Context, expected: str) -> None: assert context._cfg_branch_source == expected, ( f"Expected '{expected}', got '{context._cfg_branch_source}'" ) # =================================================================== # config_set - non-rich format # =================================================================== @when('I config cli branch run config set "{key}" "{value}" with format "{fmt}"') def step_run_config_set_format( context: Context, key: str, value: str, fmt: str ) -> None: context._cfg_branch_result = _runner.invoke( config_app, ["set", key, value, "--format", fmt] ) @then("the config cli branch set result should be valid JSON") def step_set_result_json(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" parsed = json.loads(result.output.strip()) assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}" context._cfg_branch_set_json = _unwrap_envelope(parsed) @then('the config cli branch set JSON should contain key "{key}"') def step_set_json_has_key(context: Context, key: str) -> None: assert key in context._cfg_branch_set_json, ( f"Key '{key}' not in {list(context._cfg_branch_set_json.keys())}" ) # =================================================================== # config_set - --project flag # =================================================================== @when('I config cli branch run config set "{key}" "{value}" with project "{project}"') def step_run_config_set_project( context: Context, key: str, value: str, project: str ) -> None: context._cfg_branch_result = _runner.invoke( config_app, ["set", key, value, "--project", project] ) @then("the config cli branch set result for project should succeed") def step_set_project_succeed(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" @then('the config cli branch set result should contain scope "{scope}"') def step_set_result_scope(context: Context, scope: str) -> None: result = context._cfg_branch_result assert scope in result.output, f"Expected '{scope}' in output: {result.output}" # =================================================================== # config_get - --verbose flag # =================================================================== @when('I config cli branch run config get "{key}" with verbose') def step_run_config_get_verbose(context: Context, key: str) -> None: context._cfg_branch_result = _runner.invoke(config_app, ["get", key, "--verbose"]) @then("the config cli branch get verbose result should succeed") def step_get_verbose_succeed(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" @then("the config cli branch get verbose output should contain resolution chain") def step_get_verbose_chain(context: Context) -> None: result = context._cfg_branch_result output = result.output assert "Resolution chain" in output or "resolution_chain" in output, ( f"Expected resolution chain in output: {output}" ) # =================================================================== # config_get - non-rich + Path serialisation # =================================================================== @given('config cli branch resolve returns a Path value for "{key}"') def step_mock_resolve_path(context: Context, key: str) -> None: """Patch ConfigService.resolve so config_get sees a Path value.""" path_val = Path("/mock/test/logs") mock_resolved = ResolvedValue( key=key, value=path_val, source=ConfigLevel.DEFAULT, chain=[ {"source": ConfigLevel.CLI_FLAG.value, "value": None}, { "source": ConfigLevel.ENV_VAR.value, "value": None, "env_name": f"CLEVERAGENTS_{key.upper().replace('.', '_').replace('-', '_')}", }, {"source": ConfigLevel.PROJECT.value, "value": None}, { "source": ConfigLevel.GLOBAL.value, "value": None, "path": "/mock/config.toml", }, {"source": ConfigLevel.DEFAULT.value, "value": Path("/mock/default/logs")}, ], ) # Patch _validate_key to accept the key, and ConfigService.resolve p1 = patch.object(config_mod, "_validate_key", return_value=key) p1.start() from cleveragents.application.services.config_service import ConfigService p2 = patch.object(ConfigService, "resolve", return_value=mock_resolved) p2.start() if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.extend([p1.stop, p2.stop]) @when('I config cli branch run config get "{key}" with format "{fmt}"') def step_run_config_get_format(context: Context, key: str, fmt: str) -> None: context._cfg_branch_result = _runner.invoke( config_app, ["get", key, "--format", fmt] ) @then("the config cli branch get result should be valid JSON") def step_get_result_json(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" parsed = json.loads(result.output.strip()) assert isinstance(parsed, dict), f"Expected dict, got {type(parsed)}" context._cfg_branch_get_json = _unwrap_envelope(parsed) @then("the config cli branch get JSON value should be a string not a Path") def step_get_json_value_string(context: Context) -> None: data = context._cfg_branch_get_json # The top-level "value" should be a string (serialised from Path) assert isinstance(data["value"], str), ( f"Expected str, got {type(data['value'])}: {data['value']}" ) assert "/mock/test/logs" in data["value"], ( f"Expected path string, got: {data['value']}" ) # =================================================================== # config_set - type coercion (bool, int, float) # =================================================================== @then("the config cli branch set JSON value should be bool false") def step_set_json_bool_false(context: Context) -> None: parsed = context._cfg_branch_set_json assert parsed["value"] is False, ( f"Expected False, got {parsed['value']} (type {type(parsed['value'])})" ) @then("the config cli branch set JSON value should be int 8") def step_set_json_int_8(context: Context) -> None: parsed = context._cfg_branch_set_json assert parsed["value"] == 8, ( f"Expected 8, got {parsed['value']} (type {type(parsed['value'])})" ) assert isinstance(parsed["value"], int), ( f"Expected int, got {type(parsed['value'])}" ) @then("the config cli branch set JSON value should be float 0.5") def step_set_json_float_half(context: Context) -> None: parsed = context._cfg_branch_set_json assert parsed["value"] == 0.5, ( f"Expected 0.5, got {parsed['value']} (type {type(parsed['value'])})" ) assert isinstance(parsed["value"], float), ( f"Expected float, got {type(parsed['value'])}" ) # =================================================================== # config_list - empty result # =================================================================== @when('I config cli branch run config list with pattern "{pattern}"') def step_run_config_list_pattern(context: Context, pattern: str) -> None: context._cfg_branch_result = _runner.invoke(config_app, ["list", pattern]) @then("the config cli branch list output should say no values match") def step_list_no_match(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" assert "No configuration values match" in result.output, ( f"Expected 'No configuration values match' in: {result.output}" ) # =================================================================== # config_list - invalid regex # =================================================================== @then("the config cli branch list result should fail with regex error") def step_list_regex_error(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code != 0, ( f"Expected non-zero exit, got {result.exit_code}: {result.output}" ) # =================================================================== # config_get - unknown key # =================================================================== @then("the config cli branch get result should fail with unknown key error") def step_get_unknown_key_error(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code != 0, ( f"Expected non-zero exit, got {result.exit_code}: {result.output}" ) # =================================================================== # config_list - JSON format + secret masking # =================================================================== @when('I config cli branch run config list with format "{fmt}"') def step_run_config_list_format(context: Context, fmt: str) -> None: context._cfg_branch_result = _runner.invoke(config_app, ["list", "--format", fmt]) @then("the config cli branch list result should be valid JSON") def step_list_result_json(context: Context) -> None: result = context._cfg_branch_result assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" parsed = json.loads(result.output.strip()) data = _unwrap_envelope(parsed) assert isinstance(data, list), f"Expected list, got {type(data)}" context._cfg_branch_list_json = data @then("the config cli branch list JSON should mask api-key values") def step_list_json_masked(context: Context) -> None: items = context._cfg_branch_list_json # Find entries whose keys contain "api-key" or "token" secret_entries = [e for e in items if "api-key" in e["key"] or "token" in e["key"]] # Secret entries with non-None values should be masked for entry in secret_entries: if entry["value"] is not None: assert entry["value"] == "****", ( f"Expected masked '****' for {entry['key']}, got: {entry['value']}" )