diff --git a/docs/template-variables.md b/docs/template-variables.md index 3ea9335..81e2620 100644 --- a/docs/template-variables.md +++ b/docs/template-variables.md @@ -66,7 +66,7 @@ STRUCT provides these built-in variables: ## Interactive Variables -Define variables that prompt users for input: +Define variables that prompt users for input. When running in interactive mode, STRUCT will display the variable's description to help users understand what value is expected: ```yaml variables: @@ -84,6 +84,39 @@ variables: default: 8080 ``` +When prompted interactively, variables with descriptions will display with contextual icons, **bold variable names**, and clean formatting: + +``` +🚀 project_name: The name of your project + Enter value [MyProject]: + +🌍 environment: Target deployment environment + Options: (1) dev, (2) staging, (3) prod + Enter value [dev]: +``` + +For variables without descriptions, a more compact format is used: + +``` +🔧 author_name []: +⚡ enable_logging [true]: +``` + +**Note**: Variable names appear in **bold** in actual terminal output for better readability. + +**Contextual Icons**: STRUCT automatically selects appropriate icons based on variable names and types: +- 🚀 Project/app names +- 🌍 Environment/deployment variables +- 🔌 Ports/network settings +- 🗄️ Database configurations +- ⚡ Boolean/toggle options +- 🔐 Authentication/secrets +- 🏷️ Versions/tags +- 📁 Paths/directories +- 🔧 General variables + +**Note**: The `description` field is displayed in interactive mode only. You can also use the legacy `help` field which works the same way. + ### Variable Types - `string`: Text values diff --git a/struct_module/template_renderer.py b/struct_module/template_renderer.py index 30a3c97..9d634f4 100644 --- a/struct_module/template_renderer.py +++ b/struct_module/template_renderer.py @@ -99,6 +99,38 @@ def render_template(self, content, vars): template = self.env.from_string(content) return template.render(vars) + def _get_variable_icon(self, var_name, var_type): + """Get contextual icon for variable based on name and type""" + var_lower = var_name.lower() + + # Project/name related + if any(keyword in var_lower for keyword in ['project', 'name', 'app', 'title']): + return '🚀' + # Environment related + elif any(keyword in var_lower for keyword in ['env', 'environment', 'stage', 'deploy']): + return '🌍' + # Database related (check before URL to prioritize database_url) + elif any(keyword in var_lower for keyword in ['db', 'database', 'sql']): + return '🗄️' + # Port/network related + elif any(keyword in var_lower for keyword in ['port', 'url', 'host', 'endpoint']): + return '🔌' + # Boolean/toggle related + elif var_type == 'boolean' or any(keyword in var_lower for keyword in ['enable', 'disable', 'toggle', 'flag']): + return '⚡' + # Authentication/security + elif any(keyword in var_lower for keyword in ['token', 'key', 'secret', 'password', 'auth']): + return '🔐' + # Version/tag related + elif any(keyword in var_lower for keyword in ['version', 'tag', 'release']): + return '🏷️' + # Path/directory related + elif any(keyword in var_lower for keyword in ['path', 'dir', 'folder']): + return '📁' + # Default + else: + return '🔧' + def prompt_for_missing_vars(self, content, vars): parsed_content = self.env.parse(content) undeclared_variables = meta.find_undeclared_variables(parsed_content) @@ -127,10 +159,29 @@ def prompt_for_missing_vars(self, content, vars): else: # Interactive prompt with enum support (choose by value or index) enum = conf.get('enum') + var_type = conf.get('type', 'string') + + # Get description if available (support both 'description' and 'help' fields) + description = conf.get('description') or conf.get('help') + + # Get contextual icon + icon = self._get_variable_icon(var, var_type) + + # ANSI color codes for formatting + BOLD = '\033[1m' + RESET = '\033[0m' + if enum: - # Build options list string like "(1) dev, (2) prod)" + # Build options list string like "(1) dev, (2) staging, (3) prod" options = ", ".join([f"({i+1}) {val}" for i, val in enumerate(enum)]) - raw = input(f"❓ Enter value for {var} [{default}] {options}: ") + + if description: + print(f"{icon} {BOLD}{var}{RESET}: {description}") + print(f" Options: {options}") + raw = input(f" Enter value [{default}]: ") or default + else: + raw = input(f"{icon} {BOLD}{var}{RESET} [{default}] {options}: ") or default + raw = raw.strip() if raw == "": user_input = default @@ -142,7 +193,11 @@ def prompt_for_missing_vars(self, content, vars): # For invalid enum input, raise immediately instead of re-prompting raise ValueError(f"Variable '{var}' must be one of {enum}, got: {raw}") else: - user_input = input(f"❓ Enter value for {var} [{default}]: ") or default + if description: + print(f"{icon} {BOLD}{var}{RESET}: {description}") + user_input = input(f" Enter value [{default}]: ") or default + else: + user_input = input(f"{icon} {BOLD}{var}{RESET} [{default}]: ") or default # Coerce and validate according to schema coerced = self._coerce_and_validate(var, user_input, conf) self.input_store.set_value(var, coerced) diff --git a/tests/test_template_renderer.py b/tests/test_template_renderer.py index 3b4850f..3ea5008 100644 --- a/tests/test_template_renderer.py +++ b/tests/test_template_renderer.py @@ -105,3 +105,102 @@ def test_render_template_with_mappings(): config_variables, input_store, non_interactive, mappings=mappings_dot) rendered_content_dot = renderer_dot.render_template(content_dot, {}) assert rendered_content_dot == "Account: 123456789" + + +def test_prompt_with_description_display(): + """Test that variable descriptions are displayed in interactive prompts with Option 4 formatting""" + config_variables = [ + {"project_name": { + "type": "string", + "description": "The name of your project", + "default": "MyProject" + }}, + {"environment": { + "type": "string", + "description": "Target deployment environment", + "enum": ["dev", "staging", "prod"], + "default": "dev" + }}, + {"old_style_help": { + "type": "string", + "help": "This uses the old 'help' field", + "default": "test" + }}, + {"no_description": { + "type": "string", + "default": "test" + }} + ] + input_store = "/tmp/input.json" + non_interactive = False + renderer = TemplateRenderer(config_variables, input_store, non_interactive) + + # Test each variable type separately to ensure proper input handling + + # Test 1: Regular variable with description (should show icon + description format) + content1 = "{{@ project_name @}}" + vars1 = {} + with patch('builtins.input', return_value="TestProject") as mock_input, \ + patch('builtins.print') as mock_print: + result_vars1 = renderer.prompt_for_missing_vars(content1, vars1) + assert result_vars1["project_name"] == "TestProject" + + # Check that the new format was printed (icon + bold var: description) + print_calls = [call.args[0] for call in mock_print.call_args_list] + assert any("🚀 \033[1mproject_name\033[0m: The name of your project" in call for call in print_calls) + + # Test 2: Enum variable with description (should show icon + description + options) + content2 = "{{@ environment @}}" + vars2 = {} + with patch('builtins.input', return_value="prod") as mock_input, \ + patch('builtins.print') as mock_print: + result_vars2 = renderer.prompt_for_missing_vars(content2, vars2) + assert result_vars2["environment"] == "prod" + + # Check that description and options were printed in new format with bold + print_calls = [call.args[0] for call in mock_print.call_args_list] + assert any("🌍 \033[1menvironment\033[0m: Target deployment environment" in call for call in print_calls) + assert any("Options: (1) dev, (2) staging, (3) prod" in call for call in print_calls) + + # Test 3: Variable with 'help' field (backward compatibility) + content3 = "{{@ old_style_help @}}" + vars3 = {} + with patch('builtins.input', return_value="help_test") as mock_input, \ + patch('builtins.print') as mock_print: + result_vars3 = renderer.prompt_for_missing_vars(content3, vars3) + assert result_vars3["old_style_help"] == "help_test" + + # Check that help was printed in new format with bold + print_calls = [call.args[0] for call in mock_print.call_args_list] + assert any("🔧 \033[1mold_style_help\033[0m: This uses the old 'help' field" in call for call in print_calls) + + # Test 4: Variable without description (should use compact format with icon) + content4 = "{{@ no_description @}}" + vars4 = {} + with patch('builtins.input', return_value="no_desc_test") as mock_input, \ + patch('builtins.print') as mock_print: + result_vars4 = renderer.prompt_for_missing_vars(content4, vars4) + assert result_vars4["no_description"] == "no_desc_test" + + # Check that no description line was printed (should use inline format) + print_calls = [call.args[0] for call in mock_print.call_args_list] + # Should not contain the two-line format with description + assert not any(": " in call and "no_description" in call for call in print_calls) + +def test_variable_icon_selection(): + """Test that appropriate icons are selected for different variable types""" + config_variables = [] + input_store = "/tmp/input.json" + non_interactive = True + renderer = TemplateRenderer(config_variables, input_store, non_interactive) + + # Test icon selection logic + assert renderer._get_variable_icon("project_name", "string") == "🚀" + assert renderer._get_variable_icon("environment", "string") == "🌍" + assert renderer._get_variable_icon("port", "integer") == "🔌" + assert renderer._get_variable_icon("enable_logging", "boolean") == "⚡" + assert renderer._get_variable_icon("api_token", "string") == "🔐" + assert renderer._get_variable_icon("database_url", "string") == "🗄️" + assert renderer._get_variable_icon("version", "string") == "🏷️" + assert renderer._get_variable_icon("config_path", "string") == "📁" + assert renderer._get_variable_icon("random_var", "string") == "🔧"