import json from pathlib import Path, PureWindowsPath from typing import Any from .discovery import DiscoveryError, validate_csproj from .models import DotnetConfig, HarnessConfig, TDDConfig DEFAULT_TEST_PATTERNS = ("{stem}Tests.cs", "{stem}Test.cs") class ConfigError(ValueError): pass def _mapping(value: Any, allowed: set[str], label: str) -> dict: if not isinstance(value, dict): raise ConfigError(f"{label} must be an object") unknown = sorted(set(value) - allowed) if unknown: raise ConfigError(f"{label} contains unexpected field: {unknown[0]}; use version 2 .NET configuration") return value def _string(value: Any, label: str) -> str: if not isinstance(value, str) or not value.strip(): raise ConfigError(f"{label} must be a non-empty string") return value def _array(value: Any, label: str, *, allow_empty=False) -> tuple[str, ...]: if not isinstance(value, list) or (not value and not allow_empty): raise ConfigError(f"{label} must be a {'non-empty ' if not allow_empty else ''}string array") return tuple(_string(item, label) for item in value) def _repo_path(root: Path, raw: str, label: str) -> Path: raw = _string(raw, label) windows = PureWindowsPath(raw) if windows.drive or windows.root or Path(raw).is_absolute(): raise ConfigError(f"{label} must be repository-relative") resolved = (root / raw.replace("\\", "/")).resolve() if not resolved.is_relative_to(root): raise ConfigError(f"{label} resolves outside the repository") return resolved def load_config(root: Path) -> HarnessConfig: root = root.resolve() path = root / ".harness/config.json" try: data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {"version": 2} except (OSError, ValueError) as exc: raise ConfigError(f"cannot read configuration {path}: {exc}") from exc data = _mapping(data, {"version", "projectType", "dotnet", "tdd"}, "config") if type(data.get("version")) is not int or data["version"] != 2: raise ConfigError("version must be 2; rewrite legacy configuration using the .NET config example") project_type = data.get("projectType", "auto") if project_type not in ("auto", "dotnet"): raise ConfigError("projectType must be auto or dotnet") dotnet = _mapping(data.get("dotnet", {}), {"target", "configuration", "testProjects"}, "dotnet") target = _repo_path(root, dotnet["target"], "dotnet.target") if "target" in dotnet else None if target is not None and (target.suffix.lower() not in {".sln", ".csproj"} or not target.is_file()): raise ConfigError("dotnet.target must be an existing .sln or SDK-style .csproj") configuration = _string(dotnet.get("configuration", "Debug"), "dotnet.configuration") tests = tuple(_repo_path(root, item, "dotnet.testProjects") for item in _array(dotnet["testProjects"], "dotnet.testProjects")) if "testProjects" in dotnet else () for test in tests: try: validate_csproj(test) except DiscoveryError as exc: raise ConfigError(f"dotnet.testProjects: {exc}") from exc tdd = _mapping(data.get("tdd", {}), {"testRoots", "testPatterns", "exclude"}, "tdd") roots = _array(tdd["testRoots"], "tdd.testRoots") if "testRoots" in tdd else ("tests",) test_roots = tuple(_repo_path(root, item, "tdd.testRoots") for item in roots) patterns = _array(tdd["testPatterns"], "tdd.testPatterns") if "testPatterns" in tdd else DEFAULT_TEST_PATTERNS for pattern in patterns: if "{stem}" not in pattern or not pattern.endswith(".cs") or any(char in pattern.replace("{stem}", "") for char in "{}/\\"): raise ConfigError("tdd.testPatterns must be C# filenames containing only the {stem} placeholder") exclude = _array(tdd["exclude"], "tdd.exclude", allow_empty=True) if "exclude" in tdd else () for item in exclude: _repo_path(root, item, "tdd.exclude") return HarnessConfig(2, project_type, DotnetConfig(target, configuration, tests), TDDConfig(test_roots, patterns, exclude))