Skip to content

Commit d0c4a17

Browse files
committed
feat(interactive): add reflection-driven interactive prompt engine
A generic package that builds a request body by walking any struct via reflection and prompting for each field through a small Prompter interface (survey-backed in production, a scripted fake for tests). Handles scalars, optional pointers, enums (validated through the SDK's own IsValid method), oneOf unions, parameter bags, slices and maps, with per-field input validation and re-prompt on invalid entries. No SDK or command coupling.
1 parent eff12f2 commit d0c4a17

10 files changed

Lines changed: 1388 additions & 0 deletions

pkg/interactive/builder.go

Lines changed: 446 additions & 0 deletions
Large diffs are not rendered by default.

pkg/interactive/builder_test.go

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
package interactive
2+
3+
import (
4+
"errors"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
)
10+
11+
type scalars struct {
12+
Name string `json:"name"`
13+
Count int32 `json:"count"`
14+
Ratio float64 `json:"ratio"`
15+
Enabled bool `json:"enabled"`
16+
}
17+
18+
func TestBuild_Scalars(t *testing.T) {
19+
b := &Builder{Prompter: &ScriptedPrompter{
20+
Inputs: map[string]string{"name": "widget", "count": "7", "ratio": "1.5"},
21+
Confirms: map[string]bool{"enabled": true},
22+
}}
23+
24+
var v scalars
25+
require.NoError(t, b.Build(&v))
26+
27+
assert.Equal(t, "widget", v.Name)
28+
assert.Equal(t, int32(7), v.Count)
29+
assert.Equal(t, 1.5, v.Ratio)
30+
assert.True(t, v.Enabled)
31+
}
32+
33+
type optionals struct {
34+
Note *string `json:"note,omitempty"`
35+
Max *int32 `json:"max,omitempty"`
36+
}
37+
38+
func TestBuild_OptionalSkipped(t *testing.T) {
39+
b := &Builder{Prompter: &ScriptedPrompter{}}
40+
41+
var v optionals
42+
require.NoError(t, b.Build(&v))
43+
44+
assert.Nil(t, v.Note)
45+
assert.Nil(t, v.Max)
46+
}
47+
48+
func TestBuild_OptionalSet(t *testing.T) {
49+
b := &Builder{Prompter: &ScriptedPrompter{
50+
Inputs: map[string]string{"note": "hi", "max": "9"},
51+
}}
52+
53+
var v optionals
54+
require.NoError(t, b.Build(&v))
55+
56+
require.NotNil(t, v.Note)
57+
assert.Equal(t, "hi", *v.Note)
58+
require.NotNil(t, v.Max)
59+
assert.Equal(t, int32(9), *v.Max)
60+
}
61+
62+
type optionalScalars struct {
63+
Flag *bool `json:"flag,omitempty"`
64+
Score *float64 `json:"score,omitempty"`
65+
}
66+
67+
func TestBuild_OptionalBoolFloatSet(t *testing.T) {
68+
b := &Builder{Prompter: &ScriptedPrompter{
69+
Inputs: map[string]string{"flag": "true", "score": "2.5"},
70+
}}
71+
72+
var v optionalScalars
73+
require.NoError(t, b.Build(&v))
74+
75+
require.NotNil(t, v.Flag)
76+
assert.True(t, *v.Flag)
77+
require.NotNil(t, v.Score)
78+
assert.Equal(t, 2.5, *v.Score)
79+
}
80+
81+
func TestBuild_OptionalBoolFloatSkipped(t *testing.T) {
82+
b := &Builder{Prompter: &ScriptedPrompter{}}
83+
84+
var v optionalScalars
85+
require.NoError(t, b.Build(&v))
86+
87+
assert.Nil(t, v.Flag)
88+
assert.Nil(t, v.Score)
89+
}
90+
91+
func TestBuild_RequiredEmptyRejected(t *testing.T) {
92+
// A required string with no scripted answer resolves to "" and is rejected
93+
// by the validator (on a real terminal survey would re-prompt).
94+
b := &Builder{Prompter: &ScriptedPrompter{}}
95+
96+
var v scalars
97+
err := b.Build(&v)
98+
require.Error(t, err)
99+
assert.Contains(t, err.Error(), "is required")
100+
}
101+
102+
func TestBuild_TypeMismatchRejected(t *testing.T) {
103+
// A non-numeric answer for an integer field is rejected by the validator.
104+
b := &Builder{Prompter: &ScriptedPrompter{
105+
Inputs: map[string]string{"name": "x", "count": "abc", "ratio": "1"},
106+
Confirms: map[string]bool{"enabled": true},
107+
}}
108+
109+
var v scalars
110+
err := b.Build(&v)
111+
require.Error(t, err)
112+
assert.Contains(t, err.Error(), "whole number")
113+
}
114+
115+
// shade mimics an SDK enum: a named string type with an IsValid() bool method.
116+
type shade string
117+
118+
func (s shade) IsValid() bool { return s == "red" || s == "green" || s == "blue" }
119+
120+
type enumHolder struct {
121+
Shade shade `json:"shade"`
122+
}
123+
124+
func TestBuild_Enum(t *testing.T) {
125+
// Enums are validated via their IsValid() method (no registry); a valid
126+
// free-text answer is accepted.
127+
b := &Builder{Prompter: &ScriptedPrompter{Inputs: map[string]string{"shade": "green"}}}
128+
129+
var v enumHolder
130+
require.NoError(t, b.Build(&v))
131+
assert.Equal(t, shade("green"), v.Shade)
132+
}
133+
134+
func TestBuild_EnumRejectsInvalid(t *testing.T) {
135+
// An out-of-set value fails IsValid; the validator surfaces the error.
136+
b := &Builder{Prompter: &ScriptedPrompter{Inputs: map[string]string{"shade": "purple"}}}
137+
138+
var v enumHolder
139+
err := b.Build(&v)
140+
require.Error(t, err)
141+
assert.Contains(t, err.Error(), "not a valid shade")
142+
}
143+
144+
type address struct {
145+
City string `json:"city"`
146+
}
147+
148+
type person struct {
149+
ID string `json:"id"`
150+
Address *address `json:"address,omitempty"`
151+
Tags []string `json:"tags,omitempty"`
152+
}
153+
154+
func TestBuild_PrePopulatedPreserved(t *testing.T) {
155+
// ID is pre-set; builder must not re-prompt it. Only address (confirm:no)
156+
// and tags (count:0) are walked.
157+
b := &Builder{Prompter: &ScriptedPrompter{
158+
Inputs: map[string]string{"tags": "0"},
159+
}}
160+
161+
v := person{ID: "keep-me"}
162+
require.NoError(t, b.Build(&v))
163+
164+
assert.Equal(t, "keep-me", v.ID)
165+
assert.Nil(t, v.Address)
166+
assert.Empty(t, v.Tags)
167+
}
168+
169+
func TestBuild_NestedPointerStruct(t *testing.T) {
170+
b := &Builder{Prompter: &ScriptedPrompter{
171+
Inputs: map[string]string{"id": "id1", "city": "Berlin", "tags": "0"},
172+
Confirms: map[string]bool{"address": true},
173+
}}
174+
175+
var v person
176+
require.NoError(t, b.Build(&v))
177+
178+
assert.Equal(t, "id1", v.ID)
179+
require.NotNil(t, v.Address)
180+
assert.Equal(t, "Berlin", v.Address.City)
181+
}
182+
183+
func TestBuild_Slice(t *testing.T) {
184+
b := &Builder{Prompter: &ScriptedPrompter{
185+
Inputs: map[string]string{"id": "id1", "tags": "2", "tags[0]": "a", "tags[1]": "b"},
186+
}}
187+
188+
var v person
189+
// address pointer: confirm defaults to false in the fake, so it stays nil.
190+
require.NoError(t, b.Build(&v))
191+
assert.Equal(t, []string{"a", "b"}, v.Tags)
192+
}
193+
194+
type union struct {
195+
AsString *string `json:"-"`
196+
AsNumber *int32 `json:"-"`
197+
}
198+
199+
func TestBuild_Union(t *testing.T) {
200+
b := &Builder{Prompter: &ScriptedPrompter{
201+
Selects: map[string]string{"variant": "string"},
202+
Inputs: map[string]string{"union.string": "via-string"},
203+
}}
204+
205+
var v union
206+
require.NoError(t, b.Build(&v))
207+
require.NotNil(t, v.AsString)
208+
assert.Equal(t, "via-string", *v.AsString)
209+
assert.Nil(t, v.AsNumber)
210+
}
211+
212+
type stringMapHolder struct {
213+
Labels map[string]string `json:"labels,omitempty"`
214+
}
215+
216+
func TestBuild_StringMap(t *testing.T) {
217+
// Labels is a non-pointer map reached directly (no "Set?" confirm): the
218+
// engine prompts a count then that many key/value pairs.
219+
b := &Builder{Prompter: &ScriptedPrompter{
220+
Inputs: map[string]string{
221+
"entries": "2",
222+
"key[0]": "k1",
223+
"key[1]": "k2",
224+
`["k1"]`: "v1",
225+
`["k2"]`: "v2",
226+
},
227+
}}
228+
229+
var v stringMapHolder
230+
require.NoError(t, b.Build(&v))
231+
assert.Equal(t, map[string]string{"k1": "v1", "k2": "v2"}, v.Labels)
232+
}
233+
234+
type paramBag struct {
235+
Alpha *string `json:"alpha,omitempty"`
236+
Beta *string `json:"beta,omitempty"`
237+
Gamma *string `json:"gamma,omitempty"`
238+
}
239+
240+
func TestBuild_ParamBag(t *testing.T) {
241+
// threshold 2 -> 3 optional fields qualifies. Select indexes 0 and 2.
242+
b := &Builder{
243+
Prompter: &ScriptedPrompter{
244+
MultiSelects: map[string][]int{"paramBag": {0, 2}},
245+
Inputs: map[string]string{"alpha": "a-val", "gamma": "g-val"},
246+
},
247+
ParamBagThreshold: 2,
248+
}
249+
250+
var v paramBag
251+
require.NoError(t, b.Build(&v))
252+
require.NotNil(t, v.Alpha)
253+
assert.Equal(t, "a-val", *v.Alpha)
254+
assert.Nil(t, v.Beta)
255+
require.NotNil(t, v.Gamma)
256+
assert.Equal(t, "g-val", *v.Gamma)
257+
}
258+
259+
type errPrompter struct{ err error }
260+
261+
func (e errPrompter) Input(string, func(string) error) (string, error) { return "", e.err }
262+
func (e errPrompter) Confirm(string) (bool, error) { return false, e.err }
263+
func (e errPrompter) Select(string, []string) (string, error) { return "", e.err }
264+
func (e errPrompter) MultiSelect(string, []string) ([]int, error) { return nil, e.err }
265+
266+
func TestBuild_PrompterErrorPropagates(t *testing.T) {
267+
sentinel := errors.New("prompt failed")
268+
b := &Builder{Prompter: errPrompter{err: sentinel}}
269+
270+
var v scalars
271+
err := b.Build(&v)
272+
require.Error(t, err)
273+
assert.ErrorIs(t, err, sentinel)
274+
}
275+
276+
type recursive struct {
277+
Name string `json:"name"`
278+
Self *recursive `json:"self,omitempty"`
279+
}
280+
281+
func TestBuild_CycleGuardFallsBackToRawJSON(t *testing.T) {
282+
// The Self pointer recurses into the same type. Confirming "yes" enters the
283+
// pointer, and at that point the type is already on the recursion stack, so
284+
// the cycle guard fires and degrades to a raw-JSON prompt instead of looping
285+
// forever. The empty raw-JSON input leaves the nested value zero. The test
286+
// completing at all proves the guard stopped the recursion; we additionally
287+
// check the recursion is bounded one level deep (Self.Self stays nil).
288+
b := &Builder{Prompter: &ScriptedPrompter{
289+
Inputs: map[string]string{"name": "top"},
290+
Confirms: map[string]bool{"self": true},
291+
}}
292+
293+
var v recursive
294+
require.NoError(t, b.Build(&v))
295+
assert.Equal(t, "top", v.Name)
296+
// Confirm:yes allocates Self; the cycle guard then skips its contents, so
297+
// Self is a non-nil empty leaf and recursion did not continue.
298+
require.NotNil(t, v.Self)
299+
assert.Equal(t, "", v.Self.Name)
300+
assert.Nil(t, v.Self.Self)
301+
}
302+
303+
func TestBuild_MaxDepthFallsBackToRawJSON(t *testing.T) {
304+
// With MaxDepth 1, descending into the nested address struct (depth 2)
305+
// exceeds the limit and degrades to a raw-JSON prompt. Empty input leaves
306+
// the address contents zero. The build still completes without error.
307+
b := &Builder{
308+
Prompter: &ScriptedPrompter{
309+
Inputs: map[string]string{"id": "id1"},
310+
Confirms: map[string]bool{"address": true},
311+
},
312+
MaxDepth: 1,
313+
}
314+
315+
var v person
316+
require.NoError(t, b.Build(&v))
317+
assert.Equal(t, "id1", v.ID)
318+
}

pkg/interactive/classify.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package interactive
2+
3+
import (
4+
"reflect"
5+
"strings"
6+
)
7+
8+
// isUnionType reports whether t looks like an OpenAPI oneOf wrapper: a struct
9+
// whose every exported field is a pointer with no JSON name (either untagged,
10+
// as the Algolia SDK emits, or tagged json:"-").
11+
func isUnionType(t reflect.Type) bool {
12+
if t.Kind() != reflect.Struct || t.NumField() == 0 {
13+
return false
14+
}
15+
exported := 0
16+
for i := 0; i < t.NumField(); i++ {
17+
f := t.Field(i)
18+
if !f.IsExported() {
19+
continue
20+
}
21+
exported++
22+
jsonTag := f.Tag.Get("json")
23+
if f.Type.Kind() != reflect.Pointer || (jsonTag != "" && jsonTag != "-") {
24+
return false
25+
}
26+
}
27+
return exported > 0
28+
}
29+
30+
// isParamBag reports whether t is a large optional-only parameter object: more
31+
// than threshold optional exported fields and zero required fields.
32+
func isParamBag(t reflect.Type, threshold int) bool {
33+
if t.Kind() != reflect.Struct {
34+
return false
35+
}
36+
exported := 0
37+
for i := 0; i < t.NumField(); i++ {
38+
f := t.Field(i)
39+
if !f.IsExported() {
40+
continue
41+
}
42+
jsonTag := f.Tag.Get("json")
43+
if jsonTag == "" || jsonTag == "-" {
44+
continue
45+
}
46+
exported++
47+
if isRequired(f) {
48+
return false
49+
}
50+
}
51+
return exported > threshold
52+
}
53+
54+
// isRequired reports whether a struct field is required: a non-pointer with a
55+
// json tag that does not contain "omitempty".
56+
func isRequired(f reflect.StructField) bool {
57+
if f.Type.Kind() == reflect.Pointer {
58+
return false
59+
}
60+
tag := f.Tag.Get("json")
61+
if tag == "" || tag == "-" {
62+
return false
63+
}
64+
return !strings.Contains(tag, "omitempty")
65+
}
66+
67+
// shouldSkipType filters out the SDK's internal /utils helper types.
68+
func shouldSkipType(t reflect.Type) bool {
69+
return strings.Contains(t.PkgPath(), "/utils")
70+
}
71+
72+
// jsonFieldName returns the field name from a json struct tag, dropping options
73+
// like ",omitempty".
74+
func jsonFieldName(tag string) string {
75+
parts := strings.Split(tag, ",")
76+
if parts[0] != "" {
77+
return parts[0]
78+
}
79+
return tag
80+
}

0 commit comments

Comments
 (0)