"""Step definitions for config_three_scope.feature — three-scope resolution.""" from __future__ import annotations import os import shutil import tempfile import tomllib from pathlib import Path from typing import Any import tomlkit from behave import given, then, when from behave.runner import Context from cleveragents.application.services import config_service as _config_service_mod from cleveragents.application.services.config_service import ( ConfigLevel, ConfigScope, ConfigService, discover_project_root, ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _svc(context: Context) -> ConfigService: return context._three_scope_service def _write_toml(path: Path, data: dict[str, Any]) -> None: """Write *data* as TOML to *path*, creating parent dirs.""" path.parent.mkdir(parents=True, exist_ok=True) doc = tomlkit.document() for key, value in data.items(): doc[key] = value with open(path, "w") as fh: tomlkit.dump(doc, fh) def _read_toml(path: Path) -> dict[str, Any]: if not path.exists(): return {} with open(path, "rb") as fh: return tomllib.load(fh) def _cleanup(context: Context) -> None: tmpdir = getattr(context, "_three_scope_tmpdir", None) if tmpdir and os.path.isdir(tmpdir): shutil.rmtree(tmpdir, ignore_errors=True) # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a clean three-scope config environment") def step_clean_three_scope_env(context: Context) -> None: tmpdir = tempfile.mkdtemp() context._three_scope_tmpdir = tmpdir base = Path(tmpdir) # Global config dir global_dir = base / "global_config" global_dir.mkdir() # Project root (with marker) project_root = base / "project" project_root.mkdir() (project_root / "cleveragents.toml").touch() context._three_scope_global_dir = global_dir context._three_scope_project_root = project_root context._three_scope_service = ConfigService( config_dir=global_dir, config_path=global_dir / "config.toml", project_root=project_root, ) if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(lambda: _cleanup(context)) # --------------------------------------------------------------------------- # Given — scope values # --------------------------------------------------------------------------- @given('a global config value for three-scope "{key}" = "{value}"') def step_global_value(context: Context, key: str, value: str) -> None: svc = _svc(context) data = svc.read_config() data[key] = value svc.write_config(data) @given('a project config value "{key}" = "{value}"') def step_project_value(context: Context, key: str, value: str) -> None: path = context._three_scope_project_root / "config.toml" data = _read_toml(path) data[key] = value _write_toml(path, data) @given('a local config value "{key}" = "{value}"') def step_local_value(context: Context, key: str, value: str) -> None: path = context._three_scope_project_root / "config.local.toml" data = _read_toml(path) data[key] = value _write_toml(path, data) # Given — nested data for deep merge @given('a global config with nested data "{dotted}" = "{value}"') def step_global_nested(context: Context, dotted: str, value: str) -> None: svc = _svc(context) data = svc.read_config() section, key = dotted.split(".", 1) data.setdefault(section, {})[key] = value svc.write_config(data) @given('a project config with nested data "{dotted}" = "{value}"') def step_project_nested(context: Context, dotted: str, value: str) -> None: path = context._three_scope_project_root / "config.toml" data = _read_toml(path) section, key = dotted.split(".", 1) data.setdefault(section, {})[key] = value _write_toml(path, data) @given('a local config with nested data "{dotted}" = "{value}"') def step_local_nested(context: Context, dotted: str, value: str) -> None: path = context._three_scope_project_root / "config.local.toml" data = _read_toml(path) section, key = dotted.split(".", 1) data.setdefault(section, {})[key] = value _write_toml(path, data) # Given — project root discovery @given('a directory tree with "{marker}" marker at the root') def step_dir_tree_with_marker_file(context: Context, marker: str) -> None: tmpdir = tempfile.mkdtemp() context._discovery_tmpdir = tmpdir root = Path(tmpdir) / "project_root" root.mkdir() sub = root / "a" / "b" sub.mkdir(parents=True) target = root / marker if marker.startswith("."): target.mkdir(parents=True, exist_ok=True) else: target.touch() context._discovery_root = root context._discovery_sub = sub @given('a directory tree with ".cleveragents" directory marker at the root') def step_dir_tree_with_dir_marker(context: Context) -> None: tmpdir = tempfile.mkdtemp() context._discovery_tmpdir = tmpdir root = Path(tmpdir) / "project_root" root.mkdir() sub = root / "a" / "b" sub.mkdir(parents=True) (root / ".cleveragents").mkdir() context._discovery_root = root context._discovery_sub = sub @given("a directory tree with no project markers") def step_dir_tree_no_markers(context: Context) -> None: tmpdir = tempfile.mkdtemp() context._discovery_tmpdir = tmpdir context._discovery_sub = Path(tmpdir) # Temporarily replace markers with a name that cannot exist in the # ancestor chain so the walk-up never matches a real directory such # as /tmp/.cleveragents left by a prior CleverAgents installation. context._original_markers = _config_service_mod._PROJECT_ROOT_MARKERS _config_service_mod._PROJECT_ROOT_MARKERS = ("__cleveragents_nonexistent_marker__",) context._cleanup_handlers.append( lambda: setattr( _config_service_mod, "_PROJECT_ROOT_MARKERS", context._original_markers, ) ) # Given — local config file @given('a local config file with "{key}" = "{value}"') def step_local_file_with_key(context: Context, key: str, value: str) -> None: path = context._three_scope_project_root / "config.local.toml" data = _read_toml(path) data[key] = value _write_toml(path, data) # --------------------------------------------------------------------------- # When # --------------------------------------------------------------------------- @when('I resolve three-scope key "{key}"') def step_resolve_three_scope(context: Context, key: str) -> None: svc = _svc(context) context._three_scope_resolved = svc.resolve(key) @when("I read the three-scope merged config") def step_read_merged(context: Context) -> None: svc = _svc(context) context._three_scope_merged = svc.read_merged_config() @when("I discover the project root from a subdirectory") def step_discover_from_subdir(context: Context) -> None: context._discovered_root = discover_project_root(context._discovery_sub) @when("I discover the project root") def step_discover_project_root(context: Context) -> None: context._discovered_root = discover_project_root(context._discovery_sub) @when("I read the local config") def step_read_local(context: Context) -> None: svc = _svc(context) context._local_config = svc.read_local_config() @when('I set three-scope value "{key}" to "{value}" with scope "{scope}"') def step_set_scoped(context: Context, key: str, value: str, scope: str) -> None: svc = _svc(context) config_scope = ConfigScope(scope) svc.set_value(key, value, scope=config_scope) # --------------------------------------------------------------------------- # Then # --------------------------------------------------------------------------- @then('the three-scope resolved value should be "{value}"') def step_check_value(context: Context, value: str) -> None: actual = str(context._three_scope_resolved.value) assert actual == value, f"Expected '{value}', got '{actual}'" @then('the three-scope resolved source should be "{source}"') def step_check_source(context: Context, source: str) -> None: actual = context._three_scope_resolved.source.value assert actual == source, f"Expected source '{source}', got '{actual}'" @then('the merged config "{section}" should contain key "{key}" with value "{value}"') def step_merged_section_key( context: Context, section: str, key: str, value: str ) -> None: merged = context._three_scope_merged assert section in merged, f"Section '{section}' not in merged config: {merged}" sec = merged[section] assert key in sec, f"Key '{key}' not in section '{section}': {sec}" assert str(sec[key]) == value, ( f"Expected '{value}' for {section}.{key}, got '{sec[key]}'" ) @then("the discovered project root should match the marker directory") def step_root_matches(context: Context) -> None: expected = context._discovery_root.resolve() actual = context._discovered_root assert actual is not None, "Expected a project root, got None" assert actual.resolve() == expected, f"Expected {expected}, got {actual.resolve()}" @then("the discovered project root should be None") def step_root_none(context: Context) -> None: assert context._discovered_root is None, ( f"Expected None, got {context._discovered_root}" ) @then("the local config should be an empty dict") def step_local_empty(context: Context) -> None: assert context._local_config == {}, ( f"Expected empty dict, got {context._local_config}" ) @then('the local config file should contain "{key}" = "{value}"') def step_local_file_contains(context: Context, key: str, value: str) -> None: path = context._three_scope_project_root / "config.local.toml" data = _read_toml(path) assert key in data, f"Key '{key}' not in local config: {data}" assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" @then('the project config file should contain "{key}" = "{value}"') def step_project_file_contains(context: Context, key: str, value: str) -> None: path = context._three_scope_project_root / "config.toml" data = _read_toml(path) assert key in data, f"Key '{key}' not in project config: {data}" assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" @then('the global config file should contain "{key}" = "{value}"') def step_global_file_contains(context: Context, key: str, value: str) -> None: svc = _svc(context) data = svc.read_config() assert key in data, f"Key '{key}' not in global config: {data}" assert str(data[key]) == value, f"Expected '{value}', got '{data[key]}'" @then('ConfigScope should have value "{value}"') def step_scope_enum_value(context: Context, value: str) -> None: scope = ConfigScope(value) assert scope.value == value, f"Expected ConfigScope.{value}, got {scope}" @then('ConfigLevel should have value "{value}"') def step_level_enum_value(context: Context, value: str) -> None: level = ConfigLevel(value) assert level.value == value, f"Expected ConfigLevel.{value}, got {level}" @then('ConfigLevel "{value}" should exist between "{before}" and "{after}"') def step_level_ordering(context: Context, value: str, before: str, after: str) -> None: members = [m.value for m in ConfigLevel] idx_before = members.index(before) idx_value = members.index(value) idx_after = members.index(after) assert idx_before < idx_value < idx_after, ( f"Expected {before} < {value} < {after} in {members}" ) @then('the default ignore patterns should include "{pattern}"') def step_ignore_patterns(context: Context, pattern: str) -> None: from cleveragents.application.services.context_service import ( DEFAULT_IGNORE_PATTERNS, ) assert pattern in DEFAULT_IGNORE_PATTERNS, ( f"'{pattern}' not in DEFAULT_IGNORE_PATTERNS: {DEFAULT_IGNORE_PATTERNS}" )