Files
Orchestrator/shared/tools/monorepo-cli/src/monorepo/structure.py
T
2026-06-30 20:37:40 +02:00

181 lines
6.0 KiB
Python

"""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