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
2 changes: 2 additions & 0 deletions internal/engine/doc.go
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
// Package engine orchestrates workflow execution, steps, and interpolation.
//
// [InterpolateString] and [InterpolateWalk] implement ${input.*} and ${steps.*} dot paths only (§13.1 MVP).
package engine
179 changes: 179 additions & 0 deletions internal/engine/interpolation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package engine

import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
)

// StepResult is the MVP step result shape (design doc §13.2).
type StepResult struct {
Output any
Meta map[string]any
}

// Context holds values for ${input.*} and ${steps.*} interpolation (§13.1).
type Context struct {
Input map[string]any
Steps map[string]StepResult
}

var tokenRE = regexp.MustCompile(`\$\{([^}]*)\}`)

// InterpolateString replaces every ${...} token in s using dot-path lookup only (§13.1 MVP).
// Resolved values are embedded as strings: scalars and JSON for objects/arrays.
func InterpolateString(s string, ctx Context) (string, error) {
var errs []error
out := tokenRE.ReplaceAllStringFunc(s, func(full string) string {
m := tokenRE.FindStringSubmatch(full)
if m == nil {
errs = append(errs, fmt.Errorf("interpolation: malformed token %q", full))
return full
}
path := strings.TrimSpace(m[1])
if path == "" {
errs = append(errs, errors.New("interpolation: empty placeholder"))
return full
}
val, err := resolvePath(ctx, path)
if err != nil {
errs = append(errs, err)
return full
}
str, err := valueToString(val)
if err != nil {
errs = append(errs, err)
return full
}
return str
})
if len(errs) > 0 {
return out, errors.Join(errs...)
}
return out, nil
}

// InterpolateWalk walks v recursively: it interpolates string leaves and descends into
// map[string]any and []any. Other JSON-like types are left unchanged.
func InterpolateWalk(v any, ctx Context) (any, error) {
switch t := v.(type) {
case string:
return InterpolateString(t, ctx)
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
iv, err := InterpolateWalk(val, ctx)
if err != nil {
return nil, err
}
out[k] = iv
}
return out, nil
case []any:
out := make([]any, len(t))
for i := range t {
iv, err := InterpolateWalk(t[i], ctx)
if err != nil {
return nil, err
}
out[i] = iv
}
return out, nil
default:
return v, nil
}
}

func resolvePath(ctx Context, path string) (any, error) {
parts := splitPath(path)
if len(parts) < 2 {
return nil, fmt.Errorf("interpolation: path %q must use input.<field>... or steps.<id>.output|meta...", path)
}
switch parts[0] {
case "input":
if ctx.Input == nil {
return nil, fmt.Errorf("interpolation: no input in context for path %q", path)
}
return walkAny(ctx.Input, parts[1:], path)
case "steps":
if len(parts) < 3 {
return nil, fmt.Errorf("interpolation: path %q must be steps.<step_id>.output|meta...", path)
}
stepID := parts[1]
if ctx.Steps == nil {
return nil, fmt.Errorf("interpolation: unknown step %q", stepID)
}
sr, ok := ctx.Steps[stepID]
if !ok {
return nil, fmt.Errorf("interpolation: unknown step %q", stepID)
}
switch parts[2] {
case "output":
return walkAny(sr.Output, parts[3:], path)
case "meta":
if sr.Meta == nil {
return nil, fmt.Errorf("interpolation: step %q has no meta", stepID)
}
return walkAny(sr.Meta, parts[3:], path)
default:
return nil, fmt.Errorf("interpolation: steps.%s must use .output or .meta, not %q", stepID, parts[2])
}
default:
return nil, fmt.Errorf("interpolation: path must start with input or steps, not %q", parts[0])
}
}

func splitPath(path string) []string {
var parts []string
for _, p := range strings.Split(path, ".") {
p = strings.TrimSpace(p)
if p != "" {
parts = append(parts, p)
}
}
return parts
}

func walkAny(v any, parts []string, fullPath string) (any, error) {
if len(parts) == 0 {
return v, nil
}
m, ok := v.(map[string]any)
if !ok {
return nil, fmt.Errorf("interpolation: cannot resolve %q: need map at %q, got %T", fullPath, parts[0], v)
}
next, ok := m[parts[0]]
if !ok {
return nil, fmt.Errorf("interpolation: undefined path %q (missing %q)", fullPath, parts[0])
}
return walkAny(next, parts[1:], fullPath)
}

func valueToString(v any) (string, error) {
if v == nil {
return "", nil
}
switch x := v.(type) {
case string:
return x, nil
case bool:
return strconv.FormatBool(x), nil
case int:
return strconv.Itoa(x), nil
case int64:
return strconv.FormatInt(x, 10), nil
case float64:
return strconv.FormatFloat(x, 'g', -1, 64), nil
case json.Number:
return x.String(), nil
default:
b, err := json.Marshal(x)
if err != nil {
return "", fmt.Errorf("interpolation: encode value: %w", err)
}
return string(b), nil
}
}
175 changes: 175 additions & 0 deletions internal/engine/interpolation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package engine

import (
"strings"
"testing"
)

// Examples aligned with design doc §7.4 / §13.1 (repo, number, step outputs).
func TestInterpolateString_inputRepoAndNumber(t *testing.T) {
ctx := Context{
Input: map[string]any{
"repo": "acme/api",
"number": float64(42),
},
}

got, err := InterpolateString(`repo=${input.repo} number=${input.number}`, ctx)
if err != nil {
t.Fatal(err)
}
want := "repo=acme/api number=42"
if got != want {
t.Fatalf("got %q want %q", got, want)
}
}

func TestInterpolateString_stepsFetchPROutput_wholeObject(t *testing.T) {
ctx := Context{
Steps: map[string]StepResult{
"fetch_pr": {
Output: map[string]any{
"title": "Fix bug",
"id": float64(99),
},
Meta: map[string]any{"durationMs": float64(1200), "costUsd": 0.02},
},
},
}

got, err := InterpolateString(`body=${steps.fetch_pr.output}`, ctx)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, "body=") {
t.Fatalf("got %q", got)
}
if !strings.Contains(got, "Fix bug") || !strings.Contains(got, `"id":99`) {
t.Fatalf("expected JSON with PR fields, got %q", got)
}
}

func TestInterpolateString_stepsReviewOutputSummary_nested(t *testing.T) {
ctx := Context{
Steps: map[string]StepResult{
"review": {
Output: map[string]any{
"summary": "LGTM",
"findings": []any{
map[string]any{"id": "f1"},
},
},
Meta: map[string]any{"durationMs": float64(800)},
},
},
}

got, err := InterpolateString(`${steps.review.output.summary}`, ctx)
if err != nil {
t.Fatal(err)
}
if got != "LGTM" {
t.Fatalf("got %q", got)
}
}

func TestInterpolateString_stepsMetaDuration(t *testing.T) {
ctx := Context{
Steps: map[string]StepResult{
"fetch_pr": {
Output: map[string]any{},
Meta: map[string]any{"durationMs": float64(1200), "costUsd": 0.02},
},
},
}
got, err := InterpolateString(`ms=${steps.fetch_pr.meta.durationMs}`, ctx)
if err != nil {
t.Fatal(err)
}
if got != "ms=1200" {
t.Fatalf("got %q", got)
}
}

func TestInterpolateString_unknownPlaceholder_validationFriendly(t *testing.T) {
ctx := Context{Input: map[string]any{"repo": "x"}}
_, err := InterpolateString(`${input.unknown_key}`, ctx)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "undefined path") || !strings.Contains(err.Error(), "unknown_key") {
t.Fatalf("expected undefined path detail, got: %v", err)
}
}

func TestInterpolateString_unknownStep(t *testing.T) {
ctx := Context{Steps: map[string]StepResult{}}
_, err := InterpolateString(`${steps.missing.output}`, ctx)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "unknown step") {
t.Fatalf("got %v", err)
}
}

func TestInterpolateString_typeMismatch_cannotDrill(t *testing.T) {
ctx := Context{
Steps: map[string]StepResult{
"fetch_pr": {
Output: "not-a-map",
Meta: map[string]any{},
},
},
}
_, err := InterpolateString(`${steps.fetch_pr.output.title}`, ctx)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "cannot resolve") {
t.Fatalf("got %v", err)
}
}

func TestInterpolateWalk_mapNested(t *testing.T) {
ctx := Context{
Input: map[string]any{"repo": "acme/api", "number": float64(7)},
}
in := map[string]any{
"repo": "${input.repo}",
"nested": map[string]any{"n": "${input.number}"},
}
got, err := InterpolateWalk(in, ctx)
if err != nil {
t.Fatal(err)
}
m := got.(map[string]any)
if m["repo"] != "acme/api" {
t.Fatalf("repo %v", m["repo"])
}
n := m["nested"].(map[string]any)
if n["n"] != "7" {
t.Fatalf("n %v", n["n"])
}
}

func TestInterpolateString_emptyPlaceholder(t *testing.T) {
_, err := InterpolateString(`x=${}`, Context{})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "empty placeholder") {
t.Fatalf("got %v", err)
}
}

func TestInterpolateString_whitespaceInsideToken(t *testing.T) {
ctx := Context{Input: map[string]any{"repo": "z"}}
got, err := InterpolateString(`${ input.repo }`, ctx)
if err != nil {
t.Fatal(err)
}
if got != "z" {
t.Fatalf("got %q", got)
}
}
Loading