From 9b47551b7b5dfd04982f2c6a2d3c647e16ec99eb Mon Sep 17 00:00:00 2001 From: ChristianLempa Date: Sun, 3 May 2026 19:30:35 +0200 Subject: [PATCH] feat(generate): support named output paths --- cli/core/module/base_commands.py | 49 ++++++++++++++++++++++++++++ cli/core/module/base_module.py | 10 +++++- tests/test_base_commands.py | 55 +++++++++++++++++++++++++++++++- 3 files changed, 112 insertions(+), 2 deletions(-) diff --git a/cli/core/module/base_commands.py b/cli/core/module/base_commands.py index 9e5df6a8..bdac640f 100644 --- a/cli/core/module/base_commands.py +++ b/cli/core/module/base_commands.py @@ -53,6 +53,7 @@ class GenerationConfig: dry_run: bool = False show_files: bool = False quiet: bool = False + name: str | None = None def list_templates(module_instance, raw: bool = False) -> list: @@ -322,6 +323,53 @@ def execute_remote_dry_run( return total_files, size_str +def _validate_output_name(name: str) -> str: + """Validate and normalize a generated output name.""" + normalized_name = name.strip() + if not normalized_name: + raise ValueError("--name cannot be empty") + if "/" in normalized_name or "\\" in normalized_name: + raise ValueError("--name must be a file name, not a path") + if normalized_name in {".", ".."}: + raise ValueError("--name cannot be '.' or '..'") + return normalized_name + + +def _prefix_path_part(name: str, part: str) -> str: + """Prefix one top-level path part with the generated output name.""" + path = Path(part) + if path.stem == "main" and path.suffix: + return f"{name}{path.suffix}" + return f"{name}_{part}" + + +def apply_output_name(rendered_files: dict[str, str], name: str | None) -> dict[str, str]: + """Rename top-level generated paths with a user-provided output name. + + The top-level entrypoint file named ``main.`` becomes ``.``. + All other top-level files or directories receive ``_`` as a prefix. + Nested path segments are preserved unchanged. + """ + if name is None: + return rendered_files + + normalized_name = _validate_output_name(name) + renamed_files: dict[str, str] = {} + for file_path, content in rendered_files.items(): + path = Path(file_path) + parts = path.parts + if not parts: + continue + + renamed_top_level = _prefix_path_part(normalized_name, parts[0]) + renamed_path = Path(renamed_top_level, *parts[1:]).as_posix() + if renamed_path in renamed_files: + raise ValueError(f"--name creates duplicate generated path: {renamed_path}") + renamed_files[renamed_path] = content + + return renamed_files + + def write_rendered_files(output_dir: Path, rendered_files: dict[str, str]) -> None: """Write rendered files to the output directory.""" output_dir.mkdir(parents=True, exist_ok=True) @@ -438,6 +486,7 @@ def generate_template(module_instance, config: GenerationConfig) -> None: # noq try: rendered_files, _variable_values = _render_template(template, config.id, display, config.interactive) + rendered_files = apply_output_name(rendered_files, config.name) if destination is None: if config.dry_run: diff --git a/cli/core/module/base_module.py b/cli/core/module/base_module.py index d527cb8a..2b6356e5 100644 --- a/cli/core/module/base_module.py +++ b/cli/core/module/base_module.py @@ -186,10 +186,17 @@ class Module(ABC): bool, Option( "--interactive/--no-interactive", - "-i/-n", help="Enable interactive prompting for variables", ), ] = True, + name: Annotated[ + str | None, + Option( + "--name", + "-n", + help="Rename top-level generated files/directories with this name", + ), + ] = None, var: Annotated[ list[str] | None, Option( @@ -235,6 +242,7 @@ class Module(ABC): interactive=interactive, var=var, var_file=var_file, + name=name, dry_run=dry_run, show_files=show_files, quiet=quiet, diff --git a/tests/test_base_commands.py b/tests/test_base_commands.py index 3b43e548..dff1580d 100644 --- a/tests/test_base_commands.py +++ b/tests/test_base_commands.py @@ -4,7 +4,7 @@ from __future__ import annotations from types import SimpleNamespace -from cli.core.module.base_commands import GenerationConfig, generate_template, list_templates +from cli.core.module.base_commands import GenerationConfig, apply_output_name, generate_template, list_templates def _noop(*_args, **_kwargs) -> None: @@ -78,6 +78,59 @@ def test_list_templates_raw_outputs_tab_separated_rows() -> None: assert display.lines == ["whoami\tWhoami\tdocker,test\t1.0.0\tdefault"] +def test_apply_output_name_renames_top_level_paths_only() -> None: + """Named generation should rename top-level outputs while preserving nested names.""" + rendered_files = { + "files/test.txt": "nested", + "main.tf": "main", + "dns.tf": "dns", + } + + assert apply_output_name(rendered_files, "servertest1") == { + "servertest1_files/test.txt": "nested", + "servertest1.tf": "main", + "servertest1_dns.tf": "dns", + } + + +def test_generate_template_applies_output_name_before_writing(monkeypatch, tmp_path) -> None: + """Generate should write renamed paths when --name is provided.""" + display = _DisplayCapture() + template = SimpleNamespace(id="terraform", slug="terraform") + module_instance = SimpleNamespace(name="terraform", display=display) + written: dict[str, object] = {} + + monkeypatch.setattr("cli.core.module.base_commands._prepare_template", lambda *_args, **_kwargs: template) + monkeypatch.setattr( + "cli.core.module.base_commands._render_template", + lambda *_args, **_kwargs: ({"files/test.txt": "nested", "main.tf": "main", "dns.tf": "dns"}, {}), + ) + monkeypatch.setattr("cli.core.module.base_commands.check_output_directory", lambda *_args, **_kwargs: []) + + def capture_write(output_dir, rendered_files): + written["output_dir"] = output_dir + written["rendered_files"] = rendered_files + + monkeypatch.setattr("cli.core.module.base_commands.write_rendered_files", capture_write) + + generate_template( + module_instance, + GenerationConfig( + id="terraform", + output=str(tmp_path), + interactive=False, + name="servertest1", + ), + ) + + assert written["output_dir"] == tmp_path + assert written["rendered_files"] == { + "servertest1_files/test.txt": "nested", + "servertest1.tf": "main", + "servertest1_dns.tf": "dns", + } + + def test_generate_template_dry_run_skips_destination_prompt_and_overwrite_check( monkeypatch, ) -> None: