from __future__ import annotations import logging from typing import Any, Callable from rich.console import Console from rich.prompt import IntPrompt, Prompt from ..display import DisplayManager from ..input import InputManager from ..template import Variable, VariableCollection logger = logging.getLogger(__name__) class PromptHandler: """Simple interactive prompt handler for collecting template variables.""" def __init__(self) -> None: self.console = Console() self.display = DisplayManager() def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool: """Handle section toggle variable and return whether section should be enabled.""" if section.required: logger.debug(f"Processing required section '{section.key}' without toggle prompt") return True if not section.toggle: return True toggle_var = section.variables.get(section.toggle) if not toggle_var: return True current_value = toggle_var.convert(toggle_var.value) new_value = self._prompt_variable(toggle_var, _required=section.required) if new_value != current_value: collected[toggle_var.name] = new_value toggle_var.value = new_value return section.is_enabled() def _should_skip_variable( self, var_name: str, section, variables: VariableCollection, section_enabled: bool, ) -> bool: """Determine if a variable should be skipped during collection.""" if section.toggle and var_name == section.toggle: return True if not variables.is_variable_satisfied(var_name): logger.debug(f"Skipping variable '{var_name}' - needs not satisfied") return True if not section_enabled: logger.debug(f"Skipping variable '{var_name}' from disabled section '{section.key}'") return True return False def _collect_variable_value(self, variable: Variable, section, collected: dict[str, Any]) -> None: """Collect a single variable value and update if changed.""" current_value = variable.convert(variable.value) new_value = self._prompt_variable(variable, _required=section.required) if variable.autogenerated and new_value is None: collected[variable.name] = None variable.value = None elif new_value != current_value: collected[variable.name] = new_value variable.value = new_value def collect_variables(self, variables: VariableCollection) -> dict[str, Any]: """Collect values for variables by iterating through sections. Args: variables: VariableCollection with organized sections and variables Returns: Dict of variable names to collected values """ input_mgr = InputManager() if not input_mgr.confirm("Customize any settings?", default=False): logger.info("User opted to keep all default values") return {} collected: dict[str, Any] = {} for _section_key, section in variables.get_sections().items(): if not section.variables: continue self.display.section(section.title, section.description) section_enabled = self._handle_section_toggle(section, collected) for var_name, variable in section.variables.items(): if self._should_skip_variable(var_name, section, variables, section_enabled): continue self._collect_variable_value(variable, section, collected) logger.info(f"Variable collection completed. Collected {len(collected)} values") return collected def _prompt_variable(self, variable: Variable, _required: bool = False) -> Any: """Prompt for a single variable value based on its type. Args: variable: The variable to prompt for _required: Whether the containing section is required (unused, kept for API compatibility) Returns: The validated value entered by the user """ logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})") # Use variable's native methods for prompt text and default value prompt_text = variable.get_prompt_text() default_value = variable.get_normalized_default() # Add lock icon before default value for sensitive or autogenerated variables if variable.sensitive or variable.autogenerated: # Format: "Prompt text 🔒 (default)" # The lock icon goes between the text and the default value in parentheses prompt_text = f"{prompt_text} {self.display.get_lock_icon()}" # Check if this specific variable is required (has no default and not autogenerated) var_is_required = variable.is_required() # If variable is required, mark it in the prompt if var_is_required: prompt_text = f"{prompt_text} [bold red]*required[/bold red]" handler = self._get_prompt_handler(variable) # Add validation hint (includes both extra text and enum options) hint = variable.get_validation_hint() if hint: # Show options/extra inline inside parentheses, before the default prompt_text = f"{prompt_text} [dim]({hint})[/dim]" while True: try: raw = handler(prompt_text, default_value) # Use Variable's centralized validation method that handles: # - Type conversion # - Autogenerated variable detection # - Required field validation return variable.validate_and_convert(raw, check_required=True) # Return the converted value (caller will update variable.value) except ValueError as exc: # Conversion/validation failed — show a consistent error message and retry self._show_validation_error(str(exc)) except Exception as e: # Unexpected error — log and retry using the stored (unconverted) value logger.error(f"Error prompting for variable '{variable.name}': {e!s}") default_value = variable.value handler = self._get_prompt_handler(variable) def _get_prompt_handler(self, variable: Variable) -> Callable: """Return the prompt function for a variable type.""" handlers = { "bool": self._prompt_bool, "int": self._prompt_int, # For enum prompts we pass the variable.extra through so options and extra # can be combined into a single inline hint. "enum": lambda text, default: self._prompt_enum( text, variable.options or [], default, _extra=getattr(variable, "extra", None), ), } return handlers.get( variable.type, lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive), ) def _show_validation_error(self, message: str) -> None: """Display validation feedback consistently.""" self.display.error(message) def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None: value = Prompt.ask( prompt_text, default=str(default) if default is not None else "", show_default=True, password=is_sensitive, ) stripped = value.strip() if value else None return stripped if stripped else None def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None: input_mgr = InputManager() if default is None: return input_mgr.confirm(prompt_text, default=None) converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on") return input_mgr.confirm(prompt_text, default=converted) def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None: converted = None if default is not None: try: converted = int(default) except (ValueError, TypeError): logger.warning(f"Invalid default integer value: {default}") return IntPrompt.ask(prompt_text, default=converted) def _prompt_enum( self, prompt_text: str, options: list[str], default: Any = None, _extra: str | None = None, ) -> str: """Prompt for enum selection with validation. Note: prompt_text should already include hint from variable.get_validation_hint() but we keep this for backward compatibility and fallback. """ if not options: return self._prompt_string(prompt_text, default) # Validate default is in options if default and str(default) not in options: default = options[0] while True: value = Prompt.ask( prompt_text, default=str(default) if default else options[0], show_default=True, ) if value in options: return value self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")