diff --git a/internal/engine/doc.go b/internal/engine/doc.go index 4aa55d2..0b99e5d 100644 --- a/internal/engine/doc.go +++ b/internal/engine/doc.go @@ -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 diff --git a/internal/engine/interpolation.go b/internal/engine/interpolation.go new file mode 100644 index 0000000..c6af68c --- /dev/null +++ b/internal/engine/interpolation.go @@ -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.... or steps..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..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 + } +} diff --git a/internal/engine/interpolation_test.go b/internal/engine/interpolation_test.go new file mode 100644 index 0000000..d4978c4 --- /dev/null +++ b/internal/engine/interpolation_test.go @@ -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) + } +}