diff --git a/internal/cmd/template_use.go b/internal/cmd/template_use.go index b08a7a5..5b22696 100644 --- a/internal/cmd/template_use.go +++ b/internal/cmd/template_use.go @@ -161,7 +161,6 @@ func (a *App) executeTemplate(templateRoot, targetDir string, opts executeOpts) defer os.RemoveAll(tmp) tmpl.Context = ctx - tmpl.ComputedDefs = nil // already applied above if err := tmpl.Execute(tmp); err != nil { return err } diff --git a/internal/cmd/template_validate.go b/internal/cmd/template_validate.go index 9b9ee20..cea7e71 100644 --- a/internal/cmd/template_validate.go +++ b/internal/cmd/template_validate.go @@ -41,6 +41,14 @@ func newTemplateValidateCmd(app *App) *cobra.Command { // Use the first option so the template engine can compare them as strings. resolveSelectDefaults(tmpl.Context) + if len(tmpl.ComputedDefs) > 0 { + computed, err := pkgtemplate.ApplyComputed(tmpl.Context, tmpl.ComputedDefs, tmpl.FuncMap(), tmpl.Delims()) + if err != nil { + return fmt.Errorf("resolving computed values: %w", err) + } + tmpl.Context = computed + } + tmp, err := os.MkdirTemp("", "specs-validate-*") if err != nil { return err diff --git a/internal/template/context.go b/internal/template/context.go index 8db5e9d..074410d 100644 --- a/internal/template/context.go +++ b/internal/template/context.go @@ -180,6 +180,7 @@ func extractComputed(raw map[string]any) (map[string]string, error) { // resolveReferencedDefaults renders string values containing the left delimiter in // topological order so that each key's pre-fill value is correct before the user is prompted. +// It returns a new map; the caller's input is never modified. func resolveReferencedDefaults(ctx map[string]any, funcMap texttemplate.FuncMap, delims specs.Delimiters) (map[string]any, error) { // Find keys whose string value is a template expression. var refKeys []string @@ -202,15 +203,19 @@ func resolveReferencedDefaults(ctx map[string]any, funcMap texttemplate.FuncMap, return nil, fmt.Errorf("referenced defaults: %w", err) } + // Copy the input map so the caller's map is not mutated. + result := make(map[string]any, len(ctx)) + maps.Copy(result, ctx) + for _, k := range sorted { - val, err := renderExpr(ctx[k].(string), ctx, funcMap, delims) + val, err := renderExpr(result[k].(string), result, funcMap, delims) if err != nil { return nil, fmt.Errorf("referenced default %q: %w", k, err) } - ctx[k] = val + result[k] = val } - return ctx, nil + return result, nil } // topoSort returns keys in an order where each key comes after all of its diff --git a/internal/template/context_internal_test.go b/internal/template/context_internal_test.go new file mode 100644 index 0000000..038431e --- /dev/null +++ b/internal/template/context_internal_test.go @@ -0,0 +1,45 @@ +package template + +import ( + "testing" + + "github.com/specsnl/specs-cli/internal/specs" +) + +func TestResolveReferencedDefaults_DoesNotMutateInput(t *testing.T) { + input := map[string]any{ + "Name": "My App", + "Slug": "{{ .Name | toKebabCase }}", + } + // Take a snapshot of the template expression before calling. + originalSlug := input["Slug"] + + fm := FuncMap(Config{}) + result, err := resolveReferencedDefaults(input, fm, specs.DefaultDelimiters) + if err != nil { + t.Fatalf("resolveReferencedDefaults: %v", err) + } + + // The input map must not have been modified. + if input["Slug"] != originalSlug { + t.Errorf("input[Slug] was mutated: got %q, want %q", input["Slug"], originalSlug) + } + + // The returned map must have the resolved value. + if result["Slug"] != "my-app" { + t.Errorf("result[Slug] = %q, want %q", result["Slug"], "my-app") + } +} + +func TestResolveReferencedDefaults_NoRefs_ReturnsSameMap(t *testing.T) { + input := map[string]any{"Name": "plain"} + fm := FuncMap(Config{}) + result, err := resolveReferencedDefaults(input, fm, specs.DefaultDelimiters) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // When there are no template refs, the original map may be returned as-is. + if result["Name"] != "plain" { + t.Errorf("result[Name] = %q, want %q", result["Name"], "plain") + } +} diff --git a/internal/template/template.go b/internal/template/template.go index 81d799e..beba330 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -63,7 +63,7 @@ type RenderWarning struct { type Template struct { Root string // path to the template root (contains project.yaml + template/) Context map[string]any // user input map with referenced defaults resolved - ComputedDefs map[string]string // raw computed definitions; resolved by ApplyComputed post-prompt + ComputedDefs map[string]string // raw computed definitions; caller must apply via ApplyComputed before Execute Conditionals Conditionals // varName → Cond; absent means always prompt Referenced map[string]bool // schema variables referenced in template files or computed expressions Metadata *Metadata // nil if __metadata.json is absent @@ -146,17 +146,10 @@ func Get(templateRoot string, cfg Config) (*Template, error) { } // Execute renders the template/ subdirectory into targetDir, which must already exist. -// If ComputedDefs is non-empty, computed values are resolved and merged into the context -// before the walk begins. +// Context must already contain all resolved values (user inputs and computed); call +// ApplyComputed before Execute when ComputedDefs are present. func (t *Template) Execute(targetDir string) error { ctx := t.Context - if len(t.ComputedDefs) > 0 { - var err error - ctx, err = ApplyComputed(t.Context, t.ComputedDefs, t.funcMap, t.cfg.delims()) - if err != nil { - return err - } - } srcRoot := filepath.Join(t.Root, specs.TemplateDirFile) diff --git a/internal/template/template_test.go b/internal/template/template_test.go index ad56599..0692e08 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -239,6 +239,13 @@ func TestExecute_ComputedValueInTemplate(t *testing.T) { t.Fatalf("Get: %v", err) } + // Caller is responsible for applying computed values before Execute. + ctx, err := pkgtemplate.ApplyComputed(tmpl.Context, tmpl.ComputedDefs, tmpl.FuncMap(), tmpl.Delims()) + if err != nil { + t.Fatalf("ApplyComputed: %v", err) + } + tmpl.Context = ctx + target := t.TempDir() if err := tmpl.Execute(target); err != nil { t.Fatalf("Execute: %v", err) @@ -250,6 +257,27 @@ func TestExecute_ComputedValueInTemplate(t *testing.T) { } } +func TestExecute_ComputedNotAppliedByExecute(t *testing.T) { + // Execute does not call ApplyComputed internally; a missing computed key causes an error. + root := buildTemplate(t, + "Name: acme\ncomputed:\n DbName: \"{{toSnakeCase .Name}}_production\"\n", + map[string][]byte{ + "config.txt": []byte("DB={{.DbName}}"), + }, + ) + + tmpl, err := pkgtemplate.Get(root, pkgtemplate.Config{}) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // Intentionally skip ApplyComputed — Execute must NOT apply it internally. + target := t.TempDir() + if err := tmpl.Execute(target); err == nil { + t.Fatal("Execute: expected error for missing computed key DbName, got nil") + } +} + func TestExecute_PassthroughDoubleBrace(t *testing.T) { // When __delimiters switches to [[ ]], the default {{ }} syntax passes through // unchanged — important for files like GitHub Actions YAML that already use {{ }}.