""" Pipeline validation and MVP (Minimum Viable Pipeline) injection. Provides validation functions to ensure pipelines meet minimum requirements and can auto-inject sensible defaults when fields are missing or invalid. """ from dataclasses import dataclass from typing import Any from engine.display import BorderMode, DisplayRegistry from engine.effects import get_registry from engine.pipeline.params import PipelineParams # Known valid values VALID_SOURCES = ["headlines", "poetry", "fixture", "empty", "pipeline-inspect"] VALID_CAMERAS = [ "feed", "scroll", "vertical", "horizontal", "omni", "floating", "bounce", "none", "", ] VALID_DISPLAYS = None # Will be populated at runtime from DisplayRegistry @dataclass class ValidationResult: """Result of validation with changes and warnings.""" valid: bool warnings: list[str] changes: list[str] config: Any # PipelineConfig (forward ref) params: PipelineParams # MVP defaults MVP_DEFAULTS = { "source": "fixture", "display": "terminal", "camera": "", # Empty = no camera stage (static viewport) "effects": [], "border": False, } def validate_pipeline_config( config: Any, params: PipelineParams, allow_unsafe: bool = False ) -> ValidationResult: """Validate pipeline configuration against MVP requirements. Args: config: PipelineConfig object (has source, display, camera, effects fields) params: PipelineParams object (has border field) allow_unsafe: If True, don't inject defaults or enforce MVP Returns: ValidationResult with validity, warnings, changes, and validated config/params """ warnings = [] changes = [] if allow_unsafe: # Still do basic validation but don't inject defaults # Always return valid=True when allow_unsafe is set warnings.extend(_validate_source(config.source)) warnings.extend(_validate_display(config.display)) warnings.extend(_validate_camera(config.camera)) warnings.extend(_validate_effects(config.effects)) warnings.extend(_validate_border(params.border)) return ValidationResult( valid=True, # Always valid with allow_unsafe warnings=warnings, changes=[], config=config, params=params, ) # MVP injection mode # Source source_issues = _validate_source(config.source) if source_issues: warnings.extend(source_issues) config.source = MVP_DEFAULTS["source"] changes.append(f"source → {MVP_DEFAULTS['source']}") # Display display_issues = _validate_display(config.display) if display_issues: warnings.extend(display_issues) config.display = MVP_DEFAULTS["display"] changes.append(f"display → {MVP_DEFAULTS['display']}") # Camera camera_issues = _validate_camera(config.camera) if camera_issues: warnings.extend(camera_issues) config.camera = MVP_DEFAULTS["camera"] changes.append("camera → static (no camera stage)") # Effects effect_issues = _validate_effects(config.effects) if effect_issues: warnings.extend(effect_issues) # Only change if all effects are invalid if len(config.effects) == 0 or all( e not in _get_valid_effects() for e in config.effects ): config.effects = MVP_DEFAULTS["effects"] changes.append("effects → [] (none)") else: # Remove invalid effects, keep valid ones valid_effects = [e for e in config.effects if e in _get_valid_effects()] if valid_effects != config.effects: config.effects = valid_effects changes.append(f"effects → {valid_effects}") # Border (in params) border_issues = _validate_border(params.border) if border_issues: warnings.extend(border_issues) params.border = MVP_DEFAULTS["border"] changes.append(f"border → {MVP_DEFAULTS['border']}") valid = len(warnings) == 0 if changes: # If we made changes, pipeline should be valid now valid = True return ValidationResult( valid=valid, warnings=warnings, changes=changes, config=config, params=params, ) def _validate_source(source: str) -> list[str]: """Validate source field.""" if not source: return ["source is empty"] if source not in VALID_SOURCES: return [f"unknown source '{source}', valid sources: {VALID_SOURCES}"] return [] def _validate_display(display: str) -> list[str]: """Validate display field.""" if not display: return ["display is empty"] # Check if display is available (lazy load registry) try: available = DisplayRegistry.list_backends() if display not in available: return [f"display '{display}' not available, available: {available}"] except Exception as e: return [f"error checking display availability: {e}"] return [] def _validate_camera(camera: str | None) -> list[str]: """Validate camera field.""" if camera is None: return ["camera is None"] # Empty string is valid (static, no camera stage) if camera == "": return [] if camera not in VALID_CAMERAS: return [f"unknown camera '{camera}', valid cameras: {VALID_CAMERAS}"] return [] def _get_valid_effects() -> set[str]: """Get set of valid effect names.""" registry = get_registry() return set(registry.list_all().keys()) def _validate_effects(effects: list[str]) -> list[str]: """Validate effects list.""" if effects is None: return ["effects is None"] valid_effects = _get_valid_effects() issues = [] for effect in effects: if effect not in valid_effects: issues.append( f"unknown effect '{effect}', valid effects: {sorted(valid_effects)}" ) return issues def _validate_border(border: bool | BorderMode) -> list[str]: """Validate border field.""" if isinstance(border, bool): return [] if isinstance(border, BorderMode): return [] return [f"invalid border value, must be bool or BorderMode, got {type(border)}"] def get_mvp_summary(config: Any, params: PipelineParams) -> str: """Get a human-readable summary of the MVP pipeline configuration.""" camera_text = "none" if not config.camera else config.camera effects_text = "none" if not config.effects else ", ".join(config.effects) return ( f"MVP Pipeline Configuration:\n" f" Source: {config.source}\n" f" Display: {config.display}\n" f" Camera: {camera_text} (static if empty)\n" f" Effects: {effects_text}\n" f" Border: {params.border}" )