"""Ordnerstruktur-Manager für das Monorepo. Verwaltet die 3-Ebenen-Ordnerhierarchie: Ebene 1 (Kontextordner) → Ebene 2 (Projektordner) → Ebene 3 (Modulordner) Verantwortung: Anlegen, Validieren und Verwalten der Monorepo-Ordnerstruktur. """ from __future__ import annotations import re from pathlib import Path from monorepo.models import Context, ProjectInfo class StructureManager: """Verwaltet die 3-Ebenen-Ordnerhierarchie des Monorepos.""" CONTEXTS = ("privat", "dhive", "bahn", "shared") NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-]{0,48}[a-z0-9]$") def __init__(self, root_path: Path) -> None: """Initialisiert den StructureManager. Args: root_path: Wurzelverzeichnis des Monorepos. """ self.root_path = root_path def validate_name(self, name: str) -> bool: """Prüft ob ein Name der kebab-case-Konvention entspricht. Regeln: - Ausschließlich Kleinbuchstaben, Ziffern und Bindestriche - Zwischen 2 und 50 Zeichen lang - Beginnt und endet nicht mit einem Bindestrich Args: name: Der zu prüfende Projektname. Returns: True wenn der Name gültig ist, False sonst. """ if not name: return False return self.NAME_PATTERN.match(name) is not None def create_project(self, context: str, name: str) -> Path: """Erstellt ein neues Projekt im gegebenen Kontext. Validiert den Namen, prüft auf Namenskonflikte und erstellt den Projektordner im korrekten Kontextordner. Args: context: Der Arbeitskontext (privat, dhive, bahn, shared). name: Der Projektname in kebab-case. Returns: Pfad zum erstellten Projektordner. Raises: ValueError: Wenn der Kontext ungültig ist, der Name die Namenskonvention verletzt, oder ein Projekt mit diesem Namen bereits existiert. """ # Kontext validieren if context not in self.CONTEXTS: raise ValueError( f"Ungültiger Kontext '{context}'. " f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}" ) # Name validieren if not self.validate_name(name): raise ValueError( f"Ungültiger Projektname '{name}'. " f"Der Name muss kebab-case sein (Kleinbuchstaben, Ziffern, " f"Bindestriche), zwischen 2 und 50 Zeichen lang, und darf " f"nicht mit einem Bindestrich beginnen oder enden." ) # Namenskonflikt prüfen project_path = self.root_path / context / name if project_path.exists(): raise ValueError( f"Namenskonflikt: Ein Projekt mit dem Namen '{name}' " f"existiert bereits im Kontext '{context}' " f"(Pfad: {project_path})." ) # Projektordner erstellen project_path.mkdir(parents=True, exist_ok=False) return project_path def list_projects(self, context: str | None = None) -> list[ProjectInfo]: """Listet alle Projekte, optional gefiltert nach Kontext. Args: context: Optionaler Kontext-Filter. Wenn None, werden alle Projekte aus allen Kontexten aufgelistet. Returns: Liste von ProjectInfo-Objekten für alle gefundenen Projekte. Raises: ValueError: Wenn ein ungültiger Kontext angegeben wird. """ if context is not None and context not in self.CONTEXTS: raise ValueError( f"Ungültiger Kontext '{context}'. " f"Erlaubte Kontexte: {', '.join(self.CONTEXTS)}" ) contexts_to_scan = (context,) if context else self.CONTEXTS projects: list[ProjectInfo] = [] for ctx in contexts_to_scan: context_dir = self.root_path / ctx if not context_dir.exists(): continue for entry in sorted(context_dir.iterdir()): if entry.is_dir() and not entry.name.startswith("."): projects.append( ProjectInfo( name=entry.name, context=ctx, path=entry, ) ) return projects def resolve_context(self, project_path: Path) -> str: """Ermittelt den Arbeitskontext eines Projekts anhand seines Pfads. Analysiert den Pfad relativ zum Monorepo-Root und extrahiert den Kontextordner (erste Ebene). Args: project_path: Absoluter oder relativer Pfad zum Projekt. Returns: Der ermittelte Arbeitskontext als String. Raises: ValueError: Wenn der Pfad nicht innerhalb eines bekannten Kontextordners liegt. """ # Pfad auflösen (absolut machen) resolved = project_path.resolve() root_resolved = self.root_path.resolve() # Prüfen ob der Pfad innerhalb des Monorepos liegt try: relative = resolved.relative_to(root_resolved) except ValueError: raise ValueError( f"Der Pfad '{project_path}' liegt nicht innerhalb " f"des Monorepos ({self.root_path})." ) # Ersten Pfadteil (Kontextordner) extrahieren parts = relative.parts if not parts: raise ValueError( f"Der Pfad '{project_path}' zeigt auf das Monorepo-Root, " f"nicht auf ein Projekt in einem Kontext." ) context_name = parts[0] if context_name not in self.CONTEXTS: raise ValueError( f"Der Pfad '{project_path}' liegt nicht in einem bekannten " f"Kontextordner. Gefunden: '{context_name}', " f"erwartet: {', '.join(self.CONTEXTS)}." ) return context_name