"""Typed TOML configuration loading.""" from __future__ import annotations from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, Mapping import re import tomllib DEFAULT_CONFIG_PATH = Path("config/local.toml") class ConfigError(ValueError): """Raised when local configuration is missing or invalid.""" @dataclass(frozen=True) class PathsConfig: app_root: Path live_prefix: Path live_game: Path logs: Path native_logs: Path @dataclass(frozen=True) class Proton7Config: proton: Path steam_root: Path compat: Path prefix: Path @dataclass(frozen=True) class GameConfig: exe: str args: tuple[str, ...] virtual_desktop: str @dataclass(frozen=True) class AppConfig: paths: PathsConfig proton7: Proton7Config game: GameConfig source: Path def to_dict(self) -> dict[str, Any]: result = asdict(self) return _paths_to_strings(result) def project_root() -> Path: return Path(__file__).resolve().parent.parent def load_config(path: str | Path | None = None) -> AppConfig: config_path = Path(path) if path is not None else project_root() / DEFAULT_CONFIG_PATH config_path = config_path.expanduser().absolute() try: with config_path.open("rb") as handle: data = tomllib.load(handle) except FileNotFoundError as exc: raise ConfigError( f"Configuration not found: {config_path}. " "Copy config/local.example.toml to config/local.toml." ) from exc except tomllib.TOMLDecodeError as exc: raise ConfigError(f"Invalid TOML in {config_path}: {exc}") from exc paths = _section(data, "paths") proton7 = _section(data, "proton7") game = _section(data, "game") virtual_desktop = _required_string(game, "virtual_desktop") if re.fullmatch(r"[1-9][0-9]*x[1-9][0-9]*", virtual_desktop) is None: raise ConfigError("game.virtual_desktop must look like 1600x900") return AppConfig( paths=PathsConfig( app_root=_required_path(paths, "app_root"), live_prefix=_required_path(paths, "live_prefix"), live_game=_required_path(paths, "live_game"), logs=_required_path(paths, "logs"), native_logs=_required_path(paths, "native_logs"), ), proton7=Proton7Config( proton=_required_path(proton7, "proton"), steam_root=_required_path(proton7, "steam_root"), compat=_required_path(proton7, "compat"), prefix=_required_path(proton7, "prefix"), ), game=GameConfig( exe=_required_string(game, "exe"), args=_required_string_tuple(game, "args"), virtual_desktop=virtual_desktop, ), source=config_path, ) def _section(data: Mapping[str, Any], name: str) -> Mapping[str, Any]: value = data.get(name) if not isinstance(value, Mapping): raise ConfigError(f"Missing or invalid [{name}] section") return value def _required_string(section: Mapping[str, Any], key: str) -> str: value = section.get(key) if not isinstance(value, str) or not value.strip(): raise ConfigError(f"{key} must be a non-empty string") return value def _required_path(section: Mapping[str, Any], key: str) -> Path: return Path(_required_string(section, key)).expanduser().absolute() def _required_string_tuple(section: Mapping[str, Any], key: str) -> tuple[str, ...]: value = section.get(key) if not isinstance(value, list) or not value or not all( isinstance(item, str) and item for item in value ): raise ConfigError(f"{key} must be a non-empty array of strings") return tuple(value) def _paths_to_strings(value: Any) -> Any: if isinstance(value, Path): return str(value) if isinstance(value, dict): return {key: _paths_to_strings(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_paths_to_strings(item) for item in value] return value