Skip to content

Commit 9b67a92

Browse files
authored
feat: variable discovery and validation improvements (#104)
Closes #94 - Defaults from environment variables via `env` or `default_from_env` in variable schema. - Type coercion: boolean, integer, float/number; string by default. - Validation: enum, regex/pattern, min/max numeric bounds. - Required handling: in non-interactive mode, missing required variables raise a clear error. - Tests added for env defaults and coercion/validation. All tests pass (95).
1 parent 4796e57 commit 9b67a92

6 files changed

Lines changed: 195 additions & 6 deletions

File tree

docs/cli-reference.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Generate the project structure.
5959
**Usage:**
6060

6161
```sh
62-
struct generate [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [-n INPUT_STORE] [-d] [-v VARS] [-b BACKUP] [-f {overwrite,skip,append,rename,backup}] [-p GLOBAL_SYSTEM_PROMPT] [--non-interactive] [--mappings-file MAPPINGS_FILE] [-o {console,file}] structure_definition base_path
62+
struct generate [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH] [-n INPUT_STORE] [-d] [--diff] [-v VARS] [-b BACKUP] [-f {overwrite,skip,append,rename,backup}] [-p GLOBAL_SYSTEM_PROMPT] [--non-interactive] [--mappings-file MAPPINGS_FILE] [-o {console,file}] structure_definition base_path
6363
```
6464

6565
**Arguments:**
@@ -69,6 +69,7 @@ struct generate [-h] [-l LOG] [-c CONFIG_FILE] [-i LOG_FILE] [-s STRUCTURES_PATH
6969
- `-s STRUCTURES_PATH, --structures-path STRUCTURES_PATH`: Path to structure definitions.
7070
- `-n INPUT_STORE, --input-store INPUT_STORE`: Path to the input store.
7171
- `-d, --dry-run`: Perform a dry run without creating any files or directories.
72+
- `--diff`: Show unified diffs for files that would be created/modified (works with `--dry-run` and in `-o console` mode).
7273
- `-v VARS, --vars VARS`: Template variables in the format KEY1=value1,KEY2=value2.
7374
- `-b BACKUP, --backup BACKUP`: Path to the backup folder.
7475
- `-f {overwrite,skip,append,rename,backup}, --file-strategy {overwrite,skip,append,rename,backup}`: Strategy for handling existing files.

docs/file-handling.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ files:
6868
6969
## Remote File Protocols
7070
71-
STRUCT supports multiple protocols for fetching remote content:
71+
STRUCT supports multiple protocols for fetching remote content (with caching and robust fallbacks):
7272
7373
### HTTP/HTTPS
7474
@@ -80,6 +80,12 @@ files:
8080
8181
### GitHub Protocols
8282
83+
STRUCT optimizes single-file fetches from GitHub by preferring `raw.githubusercontent.com` when possible and falling back to `git clone/pull` if necessary. You can control behavior with environment variables:
84+
85+
- `STRUCT_HTTP_TIMEOUT` (seconds, default 10)
86+
- `STRUCT_HTTP_RETRIES` (default 2)
87+
- `STRUCT_DENY_NETWORK=1` to skip HTTP attempts and use git fallback directly.
88+
8389
#### Standard GitHub
8490

8591
```yaml

docs/template-variables.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,47 @@ variables:
7878

7979
- `string`: Text values
8080
- `integer`: Numeric values
81+
- `number`: Floating-point values
8182
- `boolean`: True/false values
8283

84+
### Validation and Defaults
85+
86+
Interactive enum selection: when a variable defines `enum` and you are in interactive mode, STRUCT will display numbered choices and accept either the number or the exact value. Press Enter to accept the default (if any).
87+
88+
Example prompt:
89+
90+
```
91+
❓ Enter value for ENV [dev] (1) dev, (2) prod:
92+
# Typing `2` selects `prod`, typing `prod` also works.
93+
```
94+
95+
You can now enforce types and validations in your variables schema:
96+
97+
- `required: true` to require a value (non-interactive runs will error if missing)
98+
- `enum: [...]` to restrict values to a set
99+
- `regex`/`pattern` to validate string format
100+
- `min`/`max` to bound numeric values
101+
- `env` or `default_from_env` to set defaults from environment variables
102+
103+
Example:
104+
105+
```yaml
106+
variables:
107+
- IS_ENABLED:
108+
type: boolean
109+
required: true
110+
- RETRY:
111+
type: integer
112+
min: 1
113+
max: 5
114+
- ENV:
115+
type: string
116+
enum: [dev, prod]
117+
- TOKEN:
118+
type: string
119+
env: MY_TOKEN
120+
```
121+
83122
## Custom Jinja2 Filters
84123
85124
STRUCT includes custom filters for common tasks:

docs/usage.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ struct generate my-config.yaml ./output
4949
struct generate file://my-config.yaml ./output
5050
```
5151

52+
### Diff Preview Example
53+
54+
```sh
55+
struct generate --dry-run --diff file://structure.yaml ./output
56+
```
57+
5258
### Complete Example
5359

5460
```sh
@@ -66,6 +72,7 @@ struct generate \
6672

6773
- `--log`: Set logging level (DEBUG, INFO, WARNING, ERROR)
6874
- `--dry-run`: Preview actions without making changes
75+
- `--diff`: Show unified diffs for files that would be created/modified (useful with `--dry-run` and console output)
6976
- `--backup`: Specify backup directory for existing files
7077
- `--file-strategy`: Choose how to handle existing files (overwrite, skip, append, rename, backup)
7178
- `--log-file`: Write logs to specified file

struct_module/template_renderer.py

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,13 @@ def get_defaults_from_config(self):
6161
defaults = {}
6262
for item in self.config_variables:
6363
for name, content in item.items():
64+
# Explicit default value
6465
if 'default' in content:
6566
defaults[name] = content.get('default')
67+
# Default from environment variable (env or default_from_env)
68+
env_key = content.get('env') or content.get('default_from_env')
69+
if env_key and os.environ.get(env_key) is not None:
70+
defaults[name] = os.environ.get(env_key)
6671
return defaults
6772

6873

@@ -79,19 +84,104 @@ def prompt_for_missing_vars(self, content, vars):
7984
undeclared_variables = meta.find_undeclared_variables(parsed_content)
8085
self.logger.debug(f"Undeclared variables: {undeclared_variables}")
8186

87+
# Build schema lookup
88+
schema = {}
89+
for item in (self.config_variables or []):
90+
for name, conf in item.items():
91+
schema[name] = conf or {}
92+
8293
# Prompt the user for any missing variables
8394
# Suggest a default from the config if available
8495
default_values = self.get_defaults_from_config()
8596
self.logger.debug(f"Default values from config: {default_values}")
8697

8798
for var in undeclared_variables:
8899
if var not in vars:
100+
conf = schema.get(var, {})
101+
required = conf.get('required', False)
89102
default = self.input_data.get(var, default_values.get(var, ""))
90103
if self.non_interactive:
91-
user_input = default if default else "NEEDS_TO_BE_SET"
104+
if required and (default is None or default == ""):
105+
raise ValueError(f"Missing required variable '{var}' in non-interactive mode")
106+
user_input = default
92107
else:
93-
user_input = input(f"❓ Enter value for {var} [{default}]: ") or default
94-
self.input_store.set_value(var, user_input)
95-
vars[var] = user_input
108+
# Interactive prompt with enum support (choose by value or index)
109+
enum = conf.get('enum')
110+
if enum:
111+
# Build options list string like "(1) dev, (2) prod)"
112+
options = ", ".join([f"({i+1}) {val}" for i, val in enumerate(enum)])
113+
while True:
114+
raw = input(f"❓ Enter value for {var} [{default}] {options}: ")
115+
raw = raw.strip()
116+
if raw == "":
117+
user_input = default
118+
elif raw.isdigit() and 1 <= int(raw) <= len(enum):
119+
user_input = enum[int(raw) - 1]
120+
else:
121+
# accept exact match from enum
122+
if raw in enum:
123+
user_input = raw
124+
else:
125+
print(f"Invalid choice. Please enter one of: {options} or a valid value.")
126+
continue
127+
break
128+
else:
129+
user_input = input(f"❓ Enter value for {var} [{default}]: ") or default
130+
# Coerce and validate according to schema
131+
coerced = self._coerce_and_validate(var, user_input, conf)
132+
self.input_store.set_value(var, coerced)
133+
vars[var] = coerced
96134
self.input_store.save()
97135
return vars
136+
137+
def _coerce_and_validate(self, name, value, conf):
138+
# Type coercion
139+
vtype = (conf.get('type') or 'string').lower()
140+
original = value
141+
try:
142+
if vtype == 'boolean' or vtype == 'bool':
143+
if isinstance(value, bool):
144+
coerced = value
145+
elif isinstance(value, str):
146+
coerced = value.strip().lower() in ['1', 'true', 'yes', 'y', 'on']
147+
else:
148+
coerced = bool(value)
149+
elif vtype == 'number' or vtype == 'float':
150+
coerced = float(value) if value != '' and value is not None else None
151+
elif vtype == 'integer' or vtype == 'int':
152+
coerced = int(value) if value not in (None, '') else None
153+
else:
154+
coerced = '' if value is None else str(value)
155+
except Exception:
156+
raise ValueError(f"Variable '{name}' could not be coerced to {vtype} (value: {original})")
157+
158+
# Enum validation
159+
enum = conf.get('enum')
160+
if enum is not None and coerced not in enum:
161+
raise ValueError(f"Variable '{name}' must be one of {enum}, got: {coerced}")
162+
163+
# Regex validation (only for strings)
164+
pattern = conf.get('regex') or conf.get('pattern')
165+
if pattern and isinstance(coerced, str):
166+
import re as _re
167+
if _re.fullmatch(pattern, coerced) is None:
168+
raise ValueError(f"Variable '{name}' does not match required pattern: {pattern}")
169+
170+
# Min/Max validation
171+
def _as_num(x):
172+
try:
173+
return float(x)
174+
except Exception:
175+
return None
176+
minv = conf.get('min')
177+
maxv = conf.get('max')
178+
if minv is not None:
179+
cv = _as_num(coerced)
180+
if cv is not None and cv < float(minv):
181+
raise ValueError(f"Variable '{name}' must be >= {minv}, got {coerced}")
182+
if maxv is not None:
183+
cv = _as_num(coerced)
184+
if cv is not None and cv > float(maxv):
185+
raise ValueError(f"Variable '{name}' must be <= {maxv}, got {coerced}")
186+
187+
return coerced

tests/test_template_renderer.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,52 @@ def test_prompt_for_missing_vars(renderer):
2525
missing_vars = renderer.prompt_for_missing_vars(content, vars)
2626
assert missing_vars["var2"] == "Universe"
2727

28+
29+
def test_defaults_from_env_renderer(monkeypatch):
30+
config_variables = [
31+
{"TOKEN": {"type": "string", "env": "MY_TOKEN"}},
32+
]
33+
input_store = "/tmp/input.json"
34+
non_interactive = True
35+
monkeypatch.setenv("MY_TOKEN", "abc123")
36+
r = TemplateRenderer(config_variables, input_store, non_interactive)
37+
defaults = r.get_defaults_from_config()
38+
assert defaults["TOKEN"] == "abc123"
39+
40+
41+
def test_type_coercion_and_validation(monkeypatch):
42+
config_variables = [
43+
{"IS_ENABLED": {"type": "boolean", "required": True}},
44+
{"RETRY": {"type": "integer", "min": 1, "max": 5}},
45+
{"ENV": {"type": "string", "enum": ["dev", "prod"]}},
46+
]
47+
input_store = "/tmp/input.json"
48+
non_interactive = False
49+
r = TemplateRenderer(config_variables, input_store, non_interactive)
50+
content = "{{@ IS_ENABLED @}} {{@ RETRY @}} {{@ ENV @}}"
51+
52+
# Provide inputs mapped by variable name (order-agnostic)
53+
def fake_input(prompt):
54+
if 'IS_ENABLED' in prompt:
55+
return 'yes'
56+
if 'RETRY' in prompt:
57+
return '3'
58+
if 'ENV' in prompt:
59+
return 'prod'
60+
return ''
61+
with patch('builtins.input', side_effect=fake_input):
62+
vars = {}
63+
out = r.prompt_for_missing_vars(content, vars)
64+
assert out["IS_ENABLED"] is True
65+
assert out["RETRY"] == 3
66+
assert out["ENV"] == "prod"
67+
68+
# Enum violation should raise
69+
r = TemplateRenderer(config_variables, input_store, non_interactive)
70+
with patch('builtins.input', side_effect=["true", "2", "staging"]):
71+
with pytest.raises(ValueError):
72+
r.prompt_for_missing_vars(content, {})
73+
2874
def test_get_defaults_from_config(renderer):
2975
defaults = renderer.get_defaults_from_config()
3076
assert defaults == {"var1": "default1", "var2": "default2"}

0 commit comments

Comments
 (0)