"""Configuration loading and validation for dev-env-sync.""" import os from pathlib import Path from typing import Any, Dict, List, Optional, Union from dataclasses import dataclass, field import yaml from yaml import YAMLError class ConfigParseError(Exception): """Raised when configuration file cannot be parsed or is invalid.""" pass @dataclass class DotfileConfig: """Configuration for a single dotfile.""" source: str target: str create_parent: bool = True backup: bool = True ignore: Optional[List[str]] = None @dataclass class ShellConfig: """Configuration for shell settings.""" shell: str = "bash" config_file: Optional[str] = None merge_strategy: str = "replace" sections: Optional[List[str]] = None @dataclass class EditorExtension: """Configuration for an editor extension/plugin.""" name: str install: bool = True @dataclass class EditorSettings: """Configuration for editor settings.""" settings_file: Optional[str] = None extensions: List[EditorExtension] = field(default_factory=list) @dataclass class VSCodeConfig: """Configuration for VS Code settings.""" settings: Optional[EditorSettings] = None user_data_dir: Optional[str] = None extensions: List[EditorExtension] = field(default_factory=list) @dataclass class NeovimConfig: """Configuration for Neovim settings.""" init_file: Optional[str] = None plugins: List[EditorExtension] = field(default_factory=list) config_dir: Optional[str] = None @dataclass class PackageManagerConfig: """Configuration for package managers.""" name: str packages: List[str] = field(default_factory=list) install_command: Optional[str] = None @dataclass class BackupConfig: """Configuration for backup behavior.""" enabled: bool = True directory: Optional[str] = None timestamp_format: str = "%Y%m%d_%H%M%S" @dataclass class ConfigSchema: """Complete configuration schema for dev-env-sync.""" version: str = "1.0" name: Optional[str] = None description: Optional[str] = None dotfiles: Dict[str, DotfileConfig] = field(default_factory=dict) shell: Optional[ShellConfig] = None editors: Dict[str, Any] = field(default_factory=dict) packages: List[PackageManagerConfig] = field(default_factory=list) backup: BackupConfig = field(default_factory=BackupConfig) platform_specific: Dict[str, Dict[str, Any]] = field(default_factory=dict) includes: Optional[List[str]] = None def to_dict(self) -> Dict[str, Any]: """Convert config to dictionary for YAML output.""" result = { "version": self.version, } if self.name: result["name"] = self.name if self.description: result["description"] = self.description if self.dotfiles: result["dotfiles"] = { src: { "source": cfg.source, "target": cfg.target, "create_parent": cfg.create_parent, "backup": cfg.backup, "ignore": cfg.ignore, } for src, cfg in self.dotfiles.items() } if self.shell: result["shell"] = { "shell": self.shell.shell, "config_file": self.shell.config_file, "merge_strategy": self.shell.merge_strategy, "sections": self.shell.sections, } if self.editors: result["editors"] = self.editors if self.packages: result["packages"] = [ { "name": pkg.name, "packages": pkg.packages, "install_command": pkg.install_command, } for pkg in self.packages ] result["backup"] = { "enabled": self.backup.enabled, "directory": self.backup.directory, "timestamp_format": self.backup.timestamp_format, } if self.platform_specific: result["platform_specific"] = self.platform_specific if self.includes: result["includes"] = self.includes return result class ConfigLoader: """Loads and validates dev-env-sync configuration files.""" DEFAULT_CONFIG_PATHS = [ "~/.dev-env-sync.yml", "~/.config/dev-env-sync/config.yml", "./.dev-env-sync.yml", "./dev-env-sync.yml", ] def __init__(self, config_path: Optional[str] = None): self.config_path = config_path self._raw_config: Optional[Dict[str, Any]] = None self._config: Optional[ConfigSchema] = None def load(self, path: Optional[str] = None) -> ConfigSchema: """Load and parse configuration from file.""" config_path = path or self.config_path self._raw_config = self._read_config_file(config_path) self._config = self._parse_and_validate(self._raw_config) return self._config def _read_config_file(self, path: Optional[str]) -> Dict[str, Any]: """Read configuration file from path or default locations.""" if path: config_file = Path(path).expanduser() if not config_file.exists(): raise ConfigParseError(f"Configuration file not found: {config_file}") return self._parse_yaml(config_file) for default_path in self.DEFAULT_CONFIG_PATHS: config_file = Path(default_path).expanduser() if config_file.exists(): return self._parse_yaml(config_file) raise ConfigParseError( f"No configuration file found. Searched: {', '.join(self.DEFAULT_CONFIG_PATHS)}" ) def _parse_yaml(self, path: Path) -> Dict[str, Any]: """Parse YAML file with error handling.""" try: with open(path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) or {} except YAMLError as e: raise ConfigParseError(f"YAML parsing error: {e}") except IOError as e: raise ConfigParseError(f"Could not read config file: {e}") def _parse_and_validate(self, raw_config: Dict[str, Any]) -> ConfigSchema: """Parse raw configuration dictionary into ConfigSchema.""" if not isinstance(raw_config, dict): raise ConfigParseError("Configuration must be a dictionary") config = ConfigSchema( version=raw_config.get("version", "1.0"), name=raw_config.get("name"), description=raw_config.get("description"), ) if "dotfiles" in raw_config: config.dotfiles = self._parse_dotfiles(raw_config["dotfiles"]) if "shell" in raw_config: config.shell = self._parse_shell(raw_config["shell"]) if "editors" in raw_config: config.editors = self._parse_editors(raw_config["editors"]) if "packages" in raw_config: config.packages = self._parse_packages(raw_config["packages"]) if "backup" in raw_config: config.backup = self._parse_backup(raw_config["backup"]) if "platform_specific" in raw_config: config.platform_specific = raw_config["platform_specific"] if "includes" in raw_config: if not isinstance(raw_config["includes"], list): raise ConfigParseError("'includes' must be a list") config.includes = raw_config["includes"] return config def _parse_dotfiles(self, dotfiles_config: Dict[str, Any]) -> Dict[str, DotfileConfig]: """Parse dotfiles configuration.""" dotfiles = {} for name, config in dotfiles_config.items(): if isinstance(config, str): dotfiles[name] = DotfileConfig( source=config, target=f"~/.{name}", ) elif isinstance(config, dict): dotfiles[name] = DotfileConfig( source=config.get("source", name), target=config.get("target", f"~/.{name}"), create_parent=config.get("create_parent", True), backup=config.get("backup", True), ignore=config.get("ignore"), ) else: raise ConfigParseError(f"Invalid dotfile configuration for '{name}'") return dotfiles def _parse_shell(self, shell_config: Union[str, Dict[str, Any]]) -> ShellConfig: """Parse shell configuration.""" if isinstance(shell_config, str): return ShellConfig(shell=shell_config) return ShellConfig( shell=shell_config.get("shell", "bash"), config_file=shell_config.get("config_file"), merge_strategy=shell_config.get("merge_strategy", "replace"), sections=shell_config.get("sections"), ) def _parse_editors(self, editors_config: Dict[str, Any]) -> Dict[str, Any]: """Parse editors configuration.""" editors = {} for editor_name, config in editors_config.items(): if editor_name == "vscode": vscode_config = {} if "settings" in config: vscode_config["settings"] = self._parse_editor_settings(config["settings"]) if "extensions" in config: vscode_config["extensions"] = [ EditorExtension(name=ext) if isinstance(ext, str) else ext for ext in config["extensions"] ] if "user_data_dir" in config: vscode_config["user_data_dir"] = config["user_data_dir"] editors[editor_name] = vscode_config elif editor_name == "neovim": neovim_config = {} if "init_file" in config: neovim_config["init_file"] = config["init_file"] if "plugins" in config: neovim_config["plugins"] = [ EditorExtension(name=plugin) if isinstance(plugin, str) else plugin for plugin in config["plugins"] ] if "config_dir" in config: neovim_config["config_dir"] = config["config_dir"] editors[editor_name] = neovim_config else: editors[editor_name] = config return editors def _parse_editor_settings(self, settings_config: Any) -> EditorSettings: """Parse editor settings configuration.""" if isinstance(settings_config, dict): extensions = [] if "extensions" in settings_config: extensions = [ EditorExtension(name=ext) if isinstance(ext, str) else ext for ext in settings_config["extensions"] ] return EditorSettings( settings_file=settings_config.get("settings_file"), extensions=extensions, ) return EditorSettings() def _parse_packages(self, packages_config: List[Any]) -> List[PackageManagerConfig]: """Parse packages configuration.""" packages = [] for pkg_config in packages_config: if isinstance(pkg_config, dict): packages.append( PackageManagerConfig( name=pkg_config.get("name", "custom"), packages=pkg_config.get("packages", []), install_command=pkg_config.get("install_command"), ) ) else: packages.append(PackageManagerConfig(name="custom", packages=[pkg_config])) return packages def _parse_backup(self, backup_config: Dict[str, Any]) -> BackupConfig: """Parse backup configuration.""" return BackupConfig( enabled=backup_config.get("enabled", True), directory=backup_config.get("directory"), timestamp_format=backup_config.get("timestamp_format", "%Y%m%d_%H%M%S"), ) @staticmethod def create_default_config() -> ConfigSchema: """Create a default configuration with sample values.""" return ConfigSchema( version="1.0", name="My Dev Environment", description="My developer environment configuration", dotfiles={ "bashrc": DotfileConfig( source="./dotfiles/.bashrc", target="~/.bashrc", backup=True, ), "zshrc": DotfileConfig( source="./dotfiles/.zshrc", target="~/.zshrc", backup=True, ), "vimrc": DotfileConfig( source="./dotfiles/.vimrc", target="~/.vimrc", backup=True, ), }, shell=ShellConfig( shell="bash", merge_strategy="replace", ), editors={ "vscode": { "extensions": [ {"name": "ms-python.python"}, {"name": "esbenp.prettier-vscode"}, ] }, "neovim": { "plugins": [ {"name": "vim-airline/vim-airline"}, {"name": "tpope/vim-fugitive"}, ] } }, packages=[ PackageManagerConfig( name="brew", packages=["git", "neovim", "tmux", "fzf", "ripgrep"], ), PackageManagerConfig( name="apt", packages=["git", "neovim", "tmux", "fzf", "ripgrep"], ), ], backup=BackupConfig( enabled=True, directory="~/.dev-env-sync-backups", ), )