Skip to content

Commit 005ea60

Browse files
authored
feat: display variable descriptions in interactive mode prompts (#117)
1 parent 0c902a6 commit 005ea60

File tree

3 files changed

+191
-4
lines changed

3 files changed

+191
-4
lines changed

docs/template-variables.md

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ STRUCT provides these built-in variables:
6666

6767
## Interactive Variables
6868

69-
Define variables that prompt users for input:
69+
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:
7070

7171
```yaml
7272
variables:
@@ -84,6 +84,39 @@ variables:
8484
default: 8080
8585
```
8686

87+
When prompted interactively, variables with descriptions will display with contextual icons, **bold variable names**, and clean formatting:
88+
89+
```
90+
🚀 project_name: The name of your project
91+
Enter value [MyProject]:
92+
93+
🌍 environment: Target deployment environment
94+
Options: (1) dev, (2) staging, (3) prod
95+
Enter value [dev]:
96+
```
97+
98+
For variables without descriptions, a more compact format is used:
99+
100+
```
101+
🔧 author_name []:
102+
⚡ enable_logging [true]:
103+
```
104+
105+
**Note**: Variable names appear in **bold** in actual terminal output for better readability.
106+
107+
**Contextual Icons**: STRUCT automatically selects appropriate icons based on variable names and types:
108+
- 🚀 Project/app names
109+
- 🌍 Environment/deployment variables
110+
- 🔌 Ports/network settings
111+
- 🗄️ Database configurations
112+
- ⚡ Boolean/toggle options
113+
- 🔐 Authentication/secrets
114+
- 🏷️ Versions/tags
115+
- 📁 Paths/directories
116+
- 🔧 General variables
117+
118+
**Note**: The `description` field is displayed in interactive mode only. You can also use the legacy `help` field which works the same way.
119+
87120
### Variable Types
88121

89122
- `string`: Text values

struct_module/template_renderer.py

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,38 @@ def render_template(self, content, vars):
9999
template = self.env.from_string(content)
100100
return template.render(vars)
101101

102+
def _get_variable_icon(self, var_name, var_type):
103+
"""Get contextual icon for variable based on name and type"""
104+
var_lower = var_name.lower()
105+
106+
# Project/name related
107+
if any(keyword in var_lower for keyword in ['project', 'name', 'app', 'title']):
108+
return '🚀'
109+
# Environment related
110+
elif any(keyword in var_lower for keyword in ['env', 'environment', 'stage', 'deploy']):
111+
return '🌍'
112+
# Database related (check before URL to prioritize database_url)
113+
elif any(keyword in var_lower for keyword in ['db', 'database', 'sql']):
114+
return '🗄️'
115+
# Port/network related
116+
elif any(keyword in var_lower for keyword in ['port', 'url', 'host', 'endpoint']):
117+
return '🔌'
118+
# Boolean/toggle related
119+
elif var_type == 'boolean' or any(keyword in var_lower for keyword in ['enable', 'disable', 'toggle', 'flag']):
120+
return '⚡'
121+
# Authentication/security
122+
elif any(keyword in var_lower for keyword in ['token', 'key', 'secret', 'password', 'auth']):
123+
return '🔐'
124+
# Version/tag related
125+
elif any(keyword in var_lower for keyword in ['version', 'tag', 'release']):
126+
return '🏷️'
127+
# Path/directory related
128+
elif any(keyword in var_lower for keyword in ['path', 'dir', 'folder']):
129+
return '📁'
130+
# Default
131+
else:
132+
return '🔧'
133+
102134
def prompt_for_missing_vars(self, content, vars):
103135
parsed_content = self.env.parse(content)
104136
undeclared_variables = meta.find_undeclared_variables(parsed_content)
@@ -127,10 +159,29 @@ def prompt_for_missing_vars(self, content, vars):
127159
else:
128160
# Interactive prompt with enum support (choose by value or index)
129161
enum = conf.get('enum')
162+
var_type = conf.get('type', 'string')
163+
164+
# Get description if available (support both 'description' and 'help' fields)
165+
description = conf.get('description') or conf.get('help')
166+
167+
# Get contextual icon
168+
icon = self._get_variable_icon(var, var_type)
169+
170+
# ANSI color codes for formatting
171+
BOLD = '\033[1m'
172+
RESET = '\033[0m'
173+
130174
if enum:
131-
# Build options list string like "(1) dev, (2) prod)"
175+
# Build options list string like "(1) dev, (2) staging, (3) prod"
132176
options = ", ".join([f"({i+1}) {val}" for i, val in enumerate(enum)])
133-
raw = input(f"❓ Enter value for {var} [{default}] {options}: ")
177+
178+
if description:
179+
print(f"{icon} {BOLD}{var}{RESET}: {description}")
180+
print(f" Options: {options}")
181+
raw = input(f" Enter value [{default}]: ") or default
182+
else:
183+
raw = input(f"{icon} {BOLD}{var}{RESET} [{default}] {options}: ") or default
184+
134185
raw = raw.strip()
135186
if raw == "":
136187
user_input = default
@@ -142,7 +193,11 @@ def prompt_for_missing_vars(self, content, vars):
142193
# For invalid enum input, raise immediately instead of re-prompting
143194
raise ValueError(f"Variable '{var}' must be one of {enum}, got: {raw}")
144195
else:
145-
user_input = input(f"❓ Enter value for {var} [{default}]: ") or default
196+
if description:
197+
print(f"{icon} {BOLD}{var}{RESET}: {description}")
198+
user_input = input(f" Enter value [{default}]: ") or default
199+
else:
200+
user_input = input(f"{icon} {BOLD}{var}{RESET} [{default}]: ") or default
146201
# Coerce and validate according to schema
147202
coerced = self._coerce_and_validate(var, user_input, conf)
148203
self.input_store.set_value(var, coerced)

tests/test_template_renderer.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,102 @@ def test_render_template_with_mappings():
105105
config_variables, input_store, non_interactive, mappings=mappings_dot)
106106
rendered_content_dot = renderer_dot.render_template(content_dot, {})
107107
assert rendered_content_dot == "Account: 123456789"
108+
109+
110+
def test_prompt_with_description_display():
111+
"""Test that variable descriptions are displayed in interactive prompts with Option 4 formatting"""
112+
config_variables = [
113+
{"project_name": {
114+
"type": "string",
115+
"description": "The name of your project",
116+
"default": "MyProject"
117+
}},
118+
{"environment": {
119+
"type": "string",
120+
"description": "Target deployment environment",
121+
"enum": ["dev", "staging", "prod"],
122+
"default": "dev"
123+
}},
124+
{"old_style_help": {
125+
"type": "string",
126+
"help": "This uses the old 'help' field",
127+
"default": "test"
128+
}},
129+
{"no_description": {
130+
"type": "string",
131+
"default": "test"
132+
}}
133+
]
134+
input_store = "/tmp/input.json"
135+
non_interactive = False
136+
renderer = TemplateRenderer(config_variables, input_store, non_interactive)
137+
138+
# Test each variable type separately to ensure proper input handling
139+
140+
# Test 1: Regular variable with description (should show icon + description format)
141+
content1 = "{{@ project_name @}}"
142+
vars1 = {}
143+
with patch('builtins.input', return_value="TestProject") as mock_input, \
144+
patch('builtins.print') as mock_print:
145+
result_vars1 = renderer.prompt_for_missing_vars(content1, vars1)
146+
assert result_vars1["project_name"] == "TestProject"
147+
148+
# Check that the new format was printed (icon + bold var: description)
149+
print_calls = [call.args[0] for call in mock_print.call_args_list]
150+
assert any("🚀 \033[1mproject_name\033[0m: The name of your project" in call for call in print_calls)
151+
152+
# Test 2: Enum variable with description (should show icon + description + options)
153+
content2 = "{{@ environment @}}"
154+
vars2 = {}
155+
with patch('builtins.input', return_value="prod") as mock_input, \
156+
patch('builtins.print') as mock_print:
157+
result_vars2 = renderer.prompt_for_missing_vars(content2, vars2)
158+
assert result_vars2["environment"] == "prod"
159+
160+
# Check that description and options were printed in new format with bold
161+
print_calls = [call.args[0] for call in mock_print.call_args_list]
162+
assert any("🌍 \033[1menvironment\033[0m: Target deployment environment" in call for call in print_calls)
163+
assert any("Options: (1) dev, (2) staging, (3) prod" in call for call in print_calls)
164+
165+
# Test 3: Variable with 'help' field (backward compatibility)
166+
content3 = "{{@ old_style_help @}}"
167+
vars3 = {}
168+
with patch('builtins.input', return_value="help_test") as mock_input, \
169+
patch('builtins.print') as mock_print:
170+
result_vars3 = renderer.prompt_for_missing_vars(content3, vars3)
171+
assert result_vars3["old_style_help"] == "help_test"
172+
173+
# Check that help was printed in new format with bold
174+
print_calls = [call.args[0] for call in mock_print.call_args_list]
175+
assert any("🔧 \033[1mold_style_help\033[0m: This uses the old 'help' field" in call for call in print_calls)
176+
177+
# Test 4: Variable without description (should use compact format with icon)
178+
content4 = "{{@ no_description @}}"
179+
vars4 = {}
180+
with patch('builtins.input', return_value="no_desc_test") as mock_input, \
181+
patch('builtins.print') as mock_print:
182+
result_vars4 = renderer.prompt_for_missing_vars(content4, vars4)
183+
assert result_vars4["no_description"] == "no_desc_test"
184+
185+
# Check that no description line was printed (should use inline format)
186+
print_calls = [call.args[0] for call in mock_print.call_args_list]
187+
# Should not contain the two-line format with description
188+
assert not any(": " in call and "no_description" in call for call in print_calls)
189+
190+
def test_variable_icon_selection():
191+
"""Test that appropriate icons are selected for different variable types"""
192+
config_variables = []
193+
input_store = "/tmp/input.json"
194+
non_interactive = True
195+
renderer = TemplateRenderer(config_variables, input_store, non_interactive)
196+
197+
# Test icon selection logic
198+
assert renderer._get_variable_icon("project_name", "string") == "🚀"
199+
assert renderer._get_variable_icon("environment", "string") == "🌍"
200+
assert renderer._get_variable_icon("port", "integer") == "🔌"
201+
assert renderer._get_variable_icon("enable_logging", "boolean") == "⚡"
202+
assert renderer._get_variable_icon("api_token", "string") == "🔐"
203+
assert renderer._get_variable_icon("database_url", "string") == "🗄️"
204+
assert renderer._get_variable_icon("version", "string") == "🏷️"
205+
assert renderer._get_variable_icon("config_path", "string") == "📁"
206+
assert renderer._get_variable_icon("random_var", "string") == "🔧"

0 commit comments

Comments
 (0)