Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion docs/template-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
61 changes: 58 additions & 3 deletions struct_module/template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
99 changes: 99 additions & 0 deletions tests/test_template_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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") == "🔧"
Loading