Skip to content

Commit c82cfdb

Browse files
authored
Merge pull request #50 from LAA-Software-Engineering/issue/16-workflow-interpolation
feat(engine): workflow interpolation for ${input.*} and ${steps.*} (issue #16)
2 parents 3ab4138 + a135e27 commit c82cfdb

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

internal/engine/doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
// Package engine orchestrates workflow execution, steps, and interpolation.
2+
//
3+
// [InterpolateString] and [InterpolateWalk] implement ${input.*} and ${steps.*} dot paths only (§13.1 MVP).
24
package engine

internal/engine/interpolation.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package engine
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"regexp"
8+
"strconv"
9+
"strings"
10+
)
11+
12+
// StepResult is the MVP step result shape (design doc §13.2).
13+
type StepResult struct {
14+
Output any
15+
Meta map[string]any
16+
}
17+
18+
// Context holds values for ${input.*} and ${steps.*} interpolation (§13.1).
19+
type Context struct {
20+
Input map[string]any
21+
Steps map[string]StepResult
22+
}
23+
24+
var tokenRE = regexp.MustCompile(`\$\{([^}]*)\}`)
25+
26+
// InterpolateString replaces every ${...} token in s using dot-path lookup only (§13.1 MVP).
27+
// Resolved values are embedded as strings: scalars and JSON for objects/arrays.
28+
func InterpolateString(s string, ctx Context) (string, error) {
29+
var errs []error
30+
out := tokenRE.ReplaceAllStringFunc(s, func(full string) string {
31+
m := tokenRE.FindStringSubmatch(full)
32+
if m == nil {
33+
errs = append(errs, fmt.Errorf("interpolation: malformed token %q", full))
34+
return full
35+
}
36+
path := strings.TrimSpace(m[1])
37+
if path == "" {
38+
errs = append(errs, errors.New("interpolation: empty placeholder"))
39+
return full
40+
}
41+
val, err := resolvePath(ctx, path)
42+
if err != nil {
43+
errs = append(errs, err)
44+
return full
45+
}
46+
str, err := valueToString(val)
47+
if err != nil {
48+
errs = append(errs, err)
49+
return full
50+
}
51+
return str
52+
})
53+
if len(errs) > 0 {
54+
return out, errors.Join(errs...)
55+
}
56+
return out, nil
57+
}
58+
59+
// InterpolateWalk walks v recursively: it interpolates string leaves and descends into
60+
// map[string]any and []any. Other JSON-like types are left unchanged.
61+
func InterpolateWalk(v any, ctx Context) (any, error) {
62+
switch t := v.(type) {
63+
case string:
64+
return InterpolateString(t, ctx)
65+
case map[string]any:
66+
out := make(map[string]any, len(t))
67+
for k, val := range t {
68+
iv, err := InterpolateWalk(val, ctx)
69+
if err != nil {
70+
return nil, err
71+
}
72+
out[k] = iv
73+
}
74+
return out, nil
75+
case []any:
76+
out := make([]any, len(t))
77+
for i := range t {
78+
iv, err := InterpolateWalk(t[i], ctx)
79+
if err != nil {
80+
return nil, err
81+
}
82+
out[i] = iv
83+
}
84+
return out, nil
85+
default:
86+
return v, nil
87+
}
88+
}
89+
90+
func resolvePath(ctx Context, path string) (any, error) {
91+
parts := splitPath(path)
92+
if len(parts) < 2 {
93+
return nil, fmt.Errorf("interpolation: path %q must use input.<field>... or steps.<id>.output|meta...", path)
94+
}
95+
switch parts[0] {
96+
case "input":
97+
if ctx.Input == nil {
98+
return nil, fmt.Errorf("interpolation: no input in context for path %q", path)
99+
}
100+
return walkAny(ctx.Input, parts[1:], path)
101+
case "steps":
102+
if len(parts) < 3 {
103+
return nil, fmt.Errorf("interpolation: path %q must be steps.<step_id>.output|meta...", path)
104+
}
105+
stepID := parts[1]
106+
if ctx.Steps == nil {
107+
return nil, fmt.Errorf("interpolation: unknown step %q", stepID)
108+
}
109+
sr, ok := ctx.Steps[stepID]
110+
if !ok {
111+
return nil, fmt.Errorf("interpolation: unknown step %q", stepID)
112+
}
113+
switch parts[2] {
114+
case "output":
115+
return walkAny(sr.Output, parts[3:], path)
116+
case "meta":
117+
if sr.Meta == nil {
118+
return nil, fmt.Errorf("interpolation: step %q has no meta", stepID)
119+
}
120+
return walkAny(sr.Meta, parts[3:], path)
121+
default:
122+
return nil, fmt.Errorf("interpolation: steps.%s must use .output or .meta, not %q", stepID, parts[2])
123+
}
124+
default:
125+
return nil, fmt.Errorf("interpolation: path must start with input or steps, not %q", parts[0])
126+
}
127+
}
128+
129+
func splitPath(path string) []string {
130+
var parts []string
131+
for _, p := range strings.Split(path, ".") {
132+
p = strings.TrimSpace(p)
133+
if p != "" {
134+
parts = append(parts, p)
135+
}
136+
}
137+
return parts
138+
}
139+
140+
func walkAny(v any, parts []string, fullPath string) (any, error) {
141+
if len(parts) == 0 {
142+
return v, nil
143+
}
144+
m, ok := v.(map[string]any)
145+
if !ok {
146+
return nil, fmt.Errorf("interpolation: cannot resolve %q: need map at %q, got %T", fullPath, parts[0], v)
147+
}
148+
next, ok := m[parts[0]]
149+
if !ok {
150+
return nil, fmt.Errorf("interpolation: undefined path %q (missing %q)", fullPath, parts[0])
151+
}
152+
return walkAny(next, parts[1:], fullPath)
153+
}
154+
155+
func valueToString(v any) (string, error) {
156+
if v == nil {
157+
return "", nil
158+
}
159+
switch x := v.(type) {
160+
case string:
161+
return x, nil
162+
case bool:
163+
return strconv.FormatBool(x), nil
164+
case int:
165+
return strconv.Itoa(x), nil
166+
case int64:
167+
return strconv.FormatInt(x, 10), nil
168+
case float64:
169+
return strconv.FormatFloat(x, 'g', -1, 64), nil
170+
case json.Number:
171+
return x.String(), nil
172+
default:
173+
b, err := json.Marshal(x)
174+
if err != nil {
175+
return "", fmt.Errorf("interpolation: encode value: %w", err)
176+
}
177+
return string(b), nil
178+
}
179+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package engine
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// Examples aligned with design doc §7.4 / §13.1 (repo, number, step outputs).
9+
func TestInterpolateString_inputRepoAndNumber(t *testing.T) {
10+
ctx := Context{
11+
Input: map[string]any{
12+
"repo": "acme/api",
13+
"number": float64(42),
14+
},
15+
}
16+
17+
got, err := InterpolateString(`repo=${input.repo} number=${input.number}`, ctx)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
want := "repo=acme/api number=42"
22+
if got != want {
23+
t.Fatalf("got %q want %q", got, want)
24+
}
25+
}
26+
27+
func TestInterpolateString_stepsFetchPROutput_wholeObject(t *testing.T) {
28+
ctx := Context{
29+
Steps: map[string]StepResult{
30+
"fetch_pr": {
31+
Output: map[string]any{
32+
"title": "Fix bug",
33+
"id": float64(99),
34+
},
35+
Meta: map[string]any{"durationMs": float64(1200), "costUsd": 0.02},
36+
},
37+
},
38+
}
39+
40+
got, err := InterpolateString(`body=${steps.fetch_pr.output}`, ctx)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if !strings.HasPrefix(got, "body=") {
45+
t.Fatalf("got %q", got)
46+
}
47+
if !strings.Contains(got, "Fix bug") || !strings.Contains(got, `"id":99`) {
48+
t.Fatalf("expected JSON with PR fields, got %q", got)
49+
}
50+
}
51+
52+
func TestInterpolateString_stepsReviewOutputSummary_nested(t *testing.T) {
53+
ctx := Context{
54+
Steps: map[string]StepResult{
55+
"review": {
56+
Output: map[string]any{
57+
"summary": "LGTM",
58+
"findings": []any{
59+
map[string]any{"id": "f1"},
60+
},
61+
},
62+
Meta: map[string]any{"durationMs": float64(800)},
63+
},
64+
},
65+
}
66+
67+
got, err := InterpolateString(`${steps.review.output.summary}`, ctx)
68+
if err != nil {
69+
t.Fatal(err)
70+
}
71+
if got != "LGTM" {
72+
t.Fatalf("got %q", got)
73+
}
74+
}
75+
76+
func TestInterpolateString_stepsMetaDuration(t *testing.T) {
77+
ctx := Context{
78+
Steps: map[string]StepResult{
79+
"fetch_pr": {
80+
Output: map[string]any{},
81+
Meta: map[string]any{"durationMs": float64(1200), "costUsd": 0.02},
82+
},
83+
},
84+
}
85+
got, err := InterpolateString(`ms=${steps.fetch_pr.meta.durationMs}`, ctx)
86+
if err != nil {
87+
t.Fatal(err)
88+
}
89+
if got != "ms=1200" {
90+
t.Fatalf("got %q", got)
91+
}
92+
}
93+
94+
func TestInterpolateString_unknownPlaceholder_validationFriendly(t *testing.T) {
95+
ctx := Context{Input: map[string]any{"repo": "x"}}
96+
_, err := InterpolateString(`${input.unknown_key}`, ctx)
97+
if err == nil {
98+
t.Fatal("expected error")
99+
}
100+
if !strings.Contains(err.Error(), "undefined path") || !strings.Contains(err.Error(), "unknown_key") {
101+
t.Fatalf("expected undefined path detail, got: %v", err)
102+
}
103+
}
104+
105+
func TestInterpolateString_unknownStep(t *testing.T) {
106+
ctx := Context{Steps: map[string]StepResult{}}
107+
_, err := InterpolateString(`${steps.missing.output}`, ctx)
108+
if err == nil {
109+
t.Fatal("expected error")
110+
}
111+
if !strings.Contains(err.Error(), "unknown step") {
112+
t.Fatalf("got %v", err)
113+
}
114+
}
115+
116+
func TestInterpolateString_typeMismatch_cannotDrill(t *testing.T) {
117+
ctx := Context{
118+
Steps: map[string]StepResult{
119+
"fetch_pr": {
120+
Output: "not-a-map",
121+
Meta: map[string]any{},
122+
},
123+
},
124+
}
125+
_, err := InterpolateString(`${steps.fetch_pr.output.title}`, ctx)
126+
if err == nil {
127+
t.Fatal("expected error")
128+
}
129+
if !strings.Contains(err.Error(), "cannot resolve") {
130+
t.Fatalf("got %v", err)
131+
}
132+
}
133+
134+
func TestInterpolateWalk_mapNested(t *testing.T) {
135+
ctx := Context{
136+
Input: map[string]any{"repo": "acme/api", "number": float64(7)},
137+
}
138+
in := map[string]any{
139+
"repo": "${input.repo}",
140+
"nested": map[string]any{"n": "${input.number}"},
141+
}
142+
got, err := InterpolateWalk(in, ctx)
143+
if err != nil {
144+
t.Fatal(err)
145+
}
146+
m := got.(map[string]any)
147+
if m["repo"] != "acme/api" {
148+
t.Fatalf("repo %v", m["repo"])
149+
}
150+
n := m["nested"].(map[string]any)
151+
if n["n"] != "7" {
152+
t.Fatalf("n %v", n["n"])
153+
}
154+
}
155+
156+
func TestInterpolateString_emptyPlaceholder(t *testing.T) {
157+
_, err := InterpolateString(`x=${}`, Context{})
158+
if err == nil {
159+
t.Fatal("expected error")
160+
}
161+
if !strings.Contains(err.Error(), "empty placeholder") {
162+
t.Fatalf("got %v", err)
163+
}
164+
}
165+
166+
func TestInterpolateString_whitespaceInsideToken(t *testing.T) {
167+
ctx := Context{Input: map[string]any{"repo": "z"}}
168+
got, err := InterpolateString(`${ input.repo }`, ctx)
169+
if err != nil {
170+
t.Fatal(err)
171+
}
172+
if got != "z" {
173+
t.Fatalf("got %q", got)
174+
}
175+
}

0 commit comments

Comments
 (0)