import json import sys from pathlib import Path from typing import Any from .models import CMakeConfig, HarnessConfig, MSBuildConfig 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") if any(ord(character) < 32 or ord(character) == 127 for character in value): raise ConfigError(f"{label} must not contain control characters") 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: raise ConfigError(f"{label} must be a non-empty string array") return tuple( _nonempty_string(item, f"{label}[{index}]") for index, item in enumerate(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") if any(key in cmake_data for key in preset_keys) and not ( all(key in cmake_data for key in preset_keys) and binary_dir is not None ): 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 ) if "tdd" in data: print("Deprecated config field 'tdd' is ignored; it may be removed.", file=sys.stderr) 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), )