import json from pathlib import Path from typing import Any from .models import CMakeConfig, HarnessConfig, MSBuildConfig, TDDConfig DEFAULT_TEST_PATTERNS = ( "{stem}_test.cpp", "{stem}_tests.cpp", "test_{stem}.cpp", "{stem}.test.cpp", ) class ConfigError(ValueError): pass def _reject_unknown(data: dict[str, Any], allowed: set[str], label: str) -> None: unknown = sorted(set(data) - allowed) if unknown: raise ConfigError(f"{label} contains unexpected field: {unknown[0]}") def _repo_path(root: Path, raw: str, label: str) -> Path: candidate = Path(raw) if candidate.is_absolute(): raise ConfigError(f"{label} must be repository-relative") resolved = (root / candidate).resolve() try: resolved.relative_to(root) except ValueError as exc: raise ConfigError(f"{label} resolves outside the repository") from exc return resolved def _mapping(value: Any, label: str) -> dict[str, Any]: if not isinstance(value, dict): raise ConfigError(f"{label} must be an object") return value def _nonempty_string(value: Any, label: str) -> str: if not isinstance(value, str) or not value: raise ConfigError(f"{label} must be a non-empty string") return value def _string_array(value: Any, label: str) -> tuple[str, ...]: if not isinstance(value, list): raise ConfigError(f"{label} must be an array") if not value or any(not isinstance(item, str) or not item for item in value): raise ConfigError(f"{label} must be a non-empty string array") return tuple(value) def load_config(root: Path) -> HarnessConfig: root = root.resolve() config_path = root / ".harness" / "config.json" if config_path.is_file(): try: data = json.loads(config_path.read_text(encoding="utf-8")) except UnicodeDecodeError as exc: raise ConfigError( f"{config_path}: configuration must be valid UTF-8: {exc}" ) from exc except json.JSONDecodeError as exc: raise ConfigError(f"{config_path}: {exc}") from exc else: data = {"version": 1} data = _mapping(data, "config") _reject_unknown(data, {"version", "projectType", "cmake", "msbuild", "tdd"}, "config") if data.get("version") != 1 or isinstance(data.get("version"), bool): raise ConfigError("version must be 1") project_type = data.get("projectType", "auto") if project_type not in {"auto", "cmake", "msbuild"}: raise ConfigError("projectType must be auto, cmake, or msbuild") cmake_data = _mapping(data.get("cmake", {}), "cmake") _reject_unknown( cmake_data, {"sourceDir", "binaryDir", "configurePreset", "buildPreset", "testPreset"}, "cmake", ) source_dir = _repo_path( root, _nonempty_string(cmake_data.get("sourceDir", "."), "cmake.sourceDir"), "cmake.sourceDir" ) binary_dir = None if "binaryDir" in cmake_data: binary_dir = _repo_path( root, _nonempty_string(cmake_data["binaryDir"], "cmake.binaryDir"), "cmake.binaryDir" ) preset_keys = ("configurePreset", "buildPreset", "testPreset", "binaryDir") if any(key in cmake_data for key in preset_keys) and not all(key in cmake_data for key in preset_keys): raise ConfigError("cmake configurePreset, buildPreset, testPreset, and binaryDir must be specified together") configure_preset = ( _nonempty_string(cmake_data["configurePreset"], "cmake.configurePreset") if "configurePreset" in cmake_data else None ) build_preset = ( _nonempty_string(cmake_data["buildPreset"], "cmake.buildPreset") if "buildPreset" in cmake_data else None ) test_preset = ( _nonempty_string(cmake_data["testPreset"], "cmake.testPreset") if "testPreset" in cmake_data else None ) msbuild_data = _mapping(data.get("msbuild", {}), "msbuild") _reject_unknown(msbuild_data, {"solution", "configuration", "platform", "testCommand"}, "msbuild") solution = None if "solution" in msbuild_data: solution = _repo_path( root, _nonempty_string(msbuild_data["solution"], "msbuild.solution"), "msbuild.solution" ) configuration = _nonempty_string(msbuild_data.get("configuration", "Debug"), "msbuild.configuration") platform = _nonempty_string(msbuild_data.get("platform", "x64"), "msbuild.platform") test_command = ( _string_array(msbuild_data["testCommand"], "msbuild.testCommand") if "testCommand" in msbuild_data else None ) tdd_data = _mapping(data.get("tdd", {}), "tdd") _reject_unknown(tdd_data, {"testRoots", "testPatterns", "exclude"}, "tdd") raw_test_roots = _string_array(tdd_data["testRoots"], "tdd.testRoots") if "testRoots" in tdd_data else ("tests", "test") test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in raw_test_roots) test_patterns = _string_array(tdd_data["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd_data else DEFAULT_TEST_PATTERNS if any("{stem}" not in pattern for pattern in test_patterns): raise ConfigError("every tdd.testPatterns entry must contain {stem}") exclude = _string_array(tdd_data["exclude"], "tdd.exclude") if "exclude" in tdd_data else () for item in exclude: _repo_path(root, item, "tdd.exclude") return HarnessConfig( version=1, project_type=project_type, cmake=CMakeConfig(source_dir, binary_dir, configure_preset, build_preset, test_preset), msbuild=MSBuildConfig(solution, configuration, platform, test_command), tdd=TDDConfig(test_roots, test_patterns, exclude), )