Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion internal/cmd/template_use.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
8 changes: 8 additions & 0 deletions internal/cmd/template_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions internal/template/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
45 changes: 45 additions & 0 deletions internal/template/context_internal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
13 changes: 3 additions & 10 deletions internal/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
28 changes: 28 additions & 0 deletions internal/template/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {{ }}.
Expand Down