diff --git a/structkit/template_renderer.py b/structkit/template_renderer.py index 9e151f7..70a4837 100644 --- a/structkit/template_renderer.py +++ b/structkit/template_renderer.py @@ -246,7 +246,9 @@ def _coerce_and_validate(self, name, value, conf): if pattern and isinstance(coerced, str): import re as _re if _re.fullmatch(pattern, coerced) is None: - raise ValueError(f"Variable '{name}' does not match required pattern: {pattern}") + raise TemplateVariableError( + f"Variable '{name}' does not match required pattern '{pattern}': got '{coerced}'" + ) # Min/Max validation def _as_num(x): @@ -259,11 +261,15 @@ def _as_num(x): if minv is not None: cv = _as_num(coerced) if cv is not None and cv < float(minv): - raise ValueError(f"Variable '{name}' must be >= {minv}, got {coerced}") + raise TemplateVariableError( + f"Variable '{name}' must be >= {minv}, got {coerced}" + ) if maxv is not None: cv = _as_num(coerced) if cv is not None and cv > float(maxv): - raise ValueError(f"Variable '{name}' must be <= {maxv}, got {coerced}") + raise TemplateVariableError( + f"Variable '{name}' must be <= {maxv}, got {coerced}" + ) return coerced diff --git a/tests/test_commands_more.py b/tests/test_commands_more.py index f1c3f7a..1025499 100644 --- a/tests/test_commands_more.py +++ b/tests/test_commands_more.py @@ -482,3 +482,53 @@ def test_generate_unreadable_input_store_exits_cleanly(parser, tmp_path, caplog) assert excinfo.value.code == 1 assert 'Traceback' not in caplog.text + + +# --------------------------------------------------------------------------- +# Template variable validation normalization (issue 167) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("var_value,constraint,expected_fragment", [ + ("Invalid Slug!", {"type": "string", "pattern": "^[a-z0-9-]+$"}, "does not match"), + (0, {"type": "integer", "min": 1}, ">= 1"), + (99, {"type": "integer", "max": 10}, "<= 10"), +]) +def test_generate_validation_error_exits_cleanly(parser, tmp_path, caplog, var_value, constraint, expected_fragment): + """Regex/min/max violations exit 1 with a clean message and no Traceback.""" + command = GenerateCommand(parser) + out_dir = tmp_path / 'out' + out_dir.mkdir() + + config = { + 'variables': [{'val': constraint}], + 'files': [{'out.txt': {'content': '{{@ val @}}'}}], + 'folders': [], + } + + store_dir = tmp_path / 'store' + store_dir.mkdir() + (store_dir / 'input.json').write_text('{}') + + with patch.object(command, '_load_yaml_config', return_value=config): + args = argparse.Namespace( + structure_definition='dummy', + base_path=str(out_dir), + structures_path=None, + dry_run=False, + diff=False, + output='file', + vars=f'val={var_value}', + backup=None, + file_strategy='overwrite', + global_system_prompt=None, + input_store=str(store_dir / 'input.json'), + non_interactive=True, + mappings_file=None, + source=None, + ) + with pytest.raises(SystemExit) as excinfo: + command.execute(args) + + assert excinfo.value.code == 1 + assert expected_fragment in caplog.text + assert 'Traceback' not in caplog.text diff --git a/tests/test_template_renderer.py b/tests/test_template_renderer.py index 5bbf25a..a617c2b 100644 --- a/tests/test_template_renderer.py +++ b/tests/test_template_renderer.py @@ -246,3 +246,61 @@ def test_enum_invalid_value_has_clean_error(tmp_path): renderer.prompt_for_missing_vars("{{@ environment @}}", {"environment": "qa"}) assert str(excinfo.value) == "Variable 'environment' must be one of: dev, staging, prod. Got: qa." + + +def test_regex_violation_raises_template_variable_error(tmp_path): + """Regex pattern mismatch raises TemplateVariableError, not plain ValueError.""" + config_variables = [ + {"slug": {"type": "string", "pattern": "^[a-z0-9-]+$"}} + ] + renderer = TemplateRenderer( + config_variables, + str(tmp_path / "input.json"), + non_interactive=True, + ) + + with pytest.raises(TemplateVariableError) as excinfo: + renderer.prompt_for_missing_vars("{{@ slug @}}", {"slug": "Invalid Slug!"}) + + msg = str(excinfo.value) + assert "slug" in msg + assert "^[a-z0-9-]+$" in msg + assert "Invalid Slug!" in msg + + +def test_min_violation_raises_template_variable_error(tmp_path): + """Value below min raises TemplateVariableError, not plain ValueError.""" + config_variables = [ + {"retries": {"type": "integer", "min": 1, "max": 10}} + ] + renderer = TemplateRenderer( + config_variables, + str(tmp_path / "input.json"), + non_interactive=True, + ) + + with pytest.raises(TemplateVariableError) as excinfo: + renderer.prompt_for_missing_vars("{{@ retries @}}", {"retries": 0}) + + msg = str(excinfo.value) + assert "retries" in msg + assert ">= 1" in msg + + +def test_max_violation_raises_template_variable_error(tmp_path): + """Value above max raises TemplateVariableError, not plain ValueError.""" + config_variables = [ + {"retries": {"type": "integer", "min": 1, "max": 10}} + ] + renderer = TemplateRenderer( + config_variables, + str(tmp_path / "input.json"), + non_interactive=True, + ) + + with pytest.raises(TemplateVariableError) as excinfo: + renderer.prompt_for_missing_vars("{{@ retries @}}", {"retries": 99}) + + msg = str(excinfo.value) + assert "retries" in msg + assert "<= 10" in msg