From 63079042dd7e8c09d2f97d1877980ccb8e228793 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Fri, 23 Oct 2026 12:21:17 +0100 Subject: [PATCH 1/2] chore(deps): TEMP replace the SDK with the local checkout Drop this commit and bump algoliasearch-client-go/v4 once the SDK release has FieldDescription and FieldExample. Co-Authored-By: Claude Sonnet 5.5 --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index 20f43803..7bd84993 100644 --- a/go.mod +++ b/go.mod @@ -77,3 +77,5 @@ require ( golang.org/x/tools v0.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect ) + +replace github.com/algolia/algoliasearch-client-go/v4 => /Users/ben.kalmus/work/api-clients-automation/clients/algoliasearch-client-go From 2f6c429d84db48fe6286c6d016df523314895831 Mon Sep 17 00:00:00 2001 From: Ben Kalmus Date: Fri, 23 Oct 2026 15:30:59 +0100 Subject: [PATCH 2/2] feat(interactive): show SDK field help and examples on every prompt Read the generated FieldDescription and FieldExample accessors and show the description above every prompt kind through one shared variadic PromptOption and PromptConfig on Input, Confirm, Select and MultiSelect. A pointer field shows its help once, on the "Set X?" confirm. Enum fields use a Select that preselects the example. Union variant options carry their description when the oneOf exposes VariantDescription and fall back to bare names otherwise. Raw JSON prompts show the field help and an example default when it is valid JSON. Guard reflective accessor lookups against mismatched signatures so a hand-written type cannot panic the builder. Co-Authored-By: Claude Sonnet 5.5 --- pkg/interactive/builder.go | 427 ++++++++++++++++++--------- pkg/interactive/builder_meta_test.go | 280 ++++++++++++++++++ pkg/interactive/builder_test.go | 40 ++- pkg/interactive/prompter.go | 83 +++++- pkg/interactive/prompter_test.go | 70 +++++ pkg/interactive/scripted_prompter.go | 8 +- 6 files changed, 750 insertions(+), 158 deletions(-) create mode 100644 pkg/interactive/builder_meta_test.go diff --git a/pkg/interactive/builder.go b/pkg/interactive/builder.go index 4c80beb4..bc96624d 100644 --- a/pkg/interactive/builder.go +++ b/pkg/interactive/builder.go @@ -17,11 +17,17 @@ const DefaultMaxDepth = 20 // treated as a parameter bag (the user multi-selects which fields to set). const DefaultParamBagThreshold = 15 +// SuggestFunc returns completion candidates for a field, where scope holds the string values entered so far keyed by JSON name. +type SuggestFunc func(fieldPath, jsonName string, scope map[string]string) []string + // Builder prompts for every field of a struct via reflection. type Builder struct { Prompter Prompter - MaxDepth int // 0 uses DefaultMaxDepth - ParamBagThreshold int // 0 uses DefaultParamBagThreshold + MaxDepth int // 0 uses DefaultMaxDepth + ParamBagThreshold int // 0 uses DefaultParamBagThreshold + Suggest SuggestFunc // nil disables dynamic completion + + scope map[string]string // string values entered so far, keyed by JSON name } func (b *Builder) maxDepth() int { @@ -49,37 +55,81 @@ func (b *Builder) Build(v any) error { if rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { return errors.New("interactive: Build needs a non-nil pointer to a struct") } - return b.buildValue(rv.Elem().Type().Name(), rv.Elem(), 0, nil) + b.scope = map[string]string{} + return b.buildValue(rv.Elem().Type().Name(), rv.Elem(), 0, nil, leafMeta{}) +} + +// leafMeta carries a field's generated help and example, and is empty when the SDK exposes none. +type leafMeta struct { + help string + example string + jsonName string +} + +func (m leafMeta) promptOptions() []PromptOption { + return []PromptOption{WithHelp(m.help), WithDefault(m.example)} +} + +// resolveFieldMeta returns zero help and example when the model has no FieldDescription or FieldExample methods. +func resolveFieldMeta(owner reflect.Value, jsonName string) leafMeta { + return leafMeta{ + help: callStringField(owner, "FieldDescription", jsonName), + example: callStringField(owner, "FieldExample", jsonName), + jsonName: jsonName, + } +} + +func callStringField(owner reflect.Value, method, arg string) string { + m := findMethod(owner, method) + if !m.IsValid() || !isStringToString(m) { + return "" + } + return m.Call([]reflect.Value{reflect.ValueOf(arg)})[0].String() +} + +// findMethod falls back to the address of owner so pointer-receiver methods are found. +func findMethod(owner reflect.Value, name string) reflect.Value { + m := owner.MethodByName(name) + if !m.IsValid() && owner.CanAddr() { + m = owner.Addr().MethodByName(name) + } + return m } -func (b *Builder) buildValue(label string, rv reflect.Value, depth int, stack []reflect.Type) error { +func isStringToString(m reflect.Value) bool { + mt := m.Type() + return mt.NumIn() == 1 && mt.NumOut() == 1 && + mt.In(0) == reflect.TypeFor[string]() && mt.Out(0) == reflect.TypeFor[string]() +} + +func (b *Builder) buildValue(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { if depth > b.maxDepth() || slices.Contains(stack, rv.Type()) { - return b.promptRawJSON(label, rv) + return b.promptRawJSON(label, rv, meta) } switch rv.Kind() { case reflect.Pointer: - return b.buildPointer(label, rv, depth, stack) + return b.buildPointer(label, rv, depth, stack, meta) case reflect.Struct: - return b.buildStruct(label, rv, depth, stack) + return b.buildStruct(label, rv, depth, stack, meta) case reflect.Slice: - return b.buildSlice(label, rv, depth, stack) + return b.buildSlice(label, rv, depth, stack, meta) case reflect.Map: - return b.buildMap(label, rv, depth, stack) + return b.buildMap(label, rv, depth, stack, meta) case reflect.String, reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Float32, reflect.Float64: - _, err := b.assignScalar(label, rv, false) + _, err := b.assignScalar(label, rv, false, meta) return err case reflect.Interface: - return b.promptRawJSON(label, rv) + return b.promptRawJSON(label, rv, meta) default: return fmt.Errorf("interactive: unsupported kind %s for %s", rv.Kind(), label) } } -func (b *Builder) inputInt(label string, bits int) (int64, error) { - s, err := b.Prompter.Input(label+" (integer)", countValidator()) +func (b *Builder) inputInt(label string, bits int, opts ...PromptOption) (int64, error) { + s, err := b.Prompter.Input(label+" (integer)", countValidator(), opts...) if err != nil { return 0, err } @@ -128,100 +178,179 @@ func enumValidator(t reflect.Type, label string, optional bool) func(string) err } } -// assignScalar prompts for a scalar value and writes it into v, which must be -// settable. optional reports whether an empty answer means "skip" (leaving v -// untouched) rather than writing the zero value, and selects the prompt hints. -// It returns whether v was set. Bool keeps a deliberate asymmetry: a required -// bool is a yes/no Confirm, while an optional *bool is free-text true/false with -// an empty answer meaning skip. -func (b *Builder) assignScalar(label string, v reflect.Value, optional bool) (bool, error) { - switch v.Kind() { - case reflect.String: - // SDK enums (named string types with an IsValid() bool method) are - // validated against the SDK's own allowed-value check; plain strings use - // the required/optional rule. - var validate func(string) error - switch { - case isEnumType(v.Type()): - validate = enumValidator(v.Type(), label, optional) - case !optional: - validate = requiredString(label) +// enumSkip is the Select option that leaves an optional enum unset. +const enumSkip = "(skip)" + +// enumValues reports ok=false unless Values() returns a slice of v's own type, so callers fall back to IsValid. +func enumValues(v reflect.Value) ([]reflect.Value, bool) { + m := findMethod(v, "Values") + if !m.IsValid() { + return nil, false + } + mt := m.Type() + if mt.NumIn() != 0 || mt.NumOut() != 1 || mt.Out(0).Kind() != reflect.Slice || mt.Out(0).Elem() != v.Type() { + return nil, false + } + list := m.Call(nil)[0] + out := make([]reflect.Value, list.Len()) + for i := range out { + out[i] = list.Index(i) + } + return out, len(out) > 0 +} + +// assignEnum writes the picked enum value into v, and an optional enum also offers enumSkip. +func (b *Builder) assignEnum(label string, v reflect.Value, optional bool, values []reflect.Value, meta leafMeta) (bool, error) { + labels := make([]string, len(values)) + for i, ev := range values { + labels[i] = fmt.Sprint(ev.Interface()) + } + options := labels + if optional { + options = append([]string{enumSkip}, labels...) + } + pick, err := b.Prompter.Select(label, options, meta.promptOptions()...) + if err != nil { + return false, err + } + if optional && pick == enumSkip { + return false, nil + } + for i, l := range labels { + if l == pick { + v.Set(values[i]) + return true, nil } - s, err := b.Prompter.Input(label, validate) - if err != nil { - return false, err + } + return false, fmt.Errorf("%q is not a valid %s", pick, v.Type().Name()) +} + +// suggestFor returns nil when there is no Suggest callback and no example, otherwise Tab fills the SDK example even without a Suggest callback. +func (b *Builder) suggestFor(path string, meta leafMeta) func(string) []string { + if b.Suggest == nil && meta.example == "" { + return nil + } + return func(string) []string { + var out []string + if b.Suggest != nil { + out = b.Suggest(path, meta.jsonName, b.scope) } - if optional && s == "" { - return false, nil + if meta.example != "" { + out = append(out, meta.example) } - v.SetString(s) - return true, nil + return out + } +} + +// assignScalar returns whether v was set, and an empty answer skips the field only when optional. +func (b *Builder) assignScalar(label string, v reflect.Value, optional bool, meta leafMeta) (bool, error) { + // SDK enums expose their allowed set via Values(), so a Select replaces free text and the value cannot be mistyped. + if values, ok := enumValues(v); ok { + return b.assignEnum(label, v, optional, values, meta) + } + switch v.Kind() { + case reflect.String: + return b.assignString(label, v, optional, meta) case reflect.Bool: - if optional { - s, err := b.Prompter.Input(label+" (true/false, empty to skip)", boolValidator()) - if err != nil || s == "" { - return false, err - } - val, perr := strconv.ParseBool(s) - if perr != nil { - return false, fmt.Errorf("invalid boolean for %s: %w", label, perr) - } - v.SetBool(val) - return true, nil - } - val, err := b.Prompter.Confirm(label) - if err != nil { - return false, err - } - v.SetBool(val) - return true, nil + return b.assignBool(label, v, optional, meta) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - hint := " (integer)" - if optional { - hint = " (integer, empty to skip)" - } - s, err := b.Prompter.Input(label+hint, intValidator(label, v.Type().Bits(), optional)) - if err != nil { - return false, err - } - if s == "" { - // Only reachable when optional: intValidator rejects empty for - // required fields, so the prompter re-prompts or errors before here. - return false, nil - } - n, perr := strconv.ParseInt(s, 10, v.Type().Bits()) - if perr != nil { - return false, fmt.Errorf("invalid integer for %s: %w", label, perr) - } - v.SetInt(n) - return true, nil + return b.assignInt(label, v, optional, meta) case reflect.Float32, reflect.Float64: - hint := " (number)" - if optional { - hint = " (number, empty to skip)" - } - s, err := b.Prompter.Input(label+hint, floatValidator(label, v.Type().Bits(), optional)) + return b.assignFloat(label, v, optional, meta) + default: + return false, fmt.Errorf("interactive: unsupported scalar kind %s for %s", v.Kind(), label) + } +} + +// stringValidator defers to the SDK's IsValid for enum-like strings, so a hand-maintained list never drifts. +func stringValidator(t reflect.Type, label string, optional bool) func(string) error { + switch { + case isEnumType(t): + return enumValidator(t, label, optional) + case !optional: + return requiredString(label) + default: + return nil + } +} + +func (b *Builder) assignString(label string, v reflect.Value, optional bool, meta leafMeta) (bool, error) { + promptOpts := meta.promptOptions() + if fn := b.suggestFor(label, meta); fn != nil { + promptOpts = append(promptOpts, WithSuggest(fn)) + } + s, err := b.Prompter.Input(label, stringValidator(v.Type(), label, optional), promptOpts...) + if err != nil || (optional && s == "") { + return false, err + } + v.SetString(s) + if meta.jsonName != "" { + b.scope[meta.jsonName] = s + } + return true, nil +} + +// assignBool asks a Confirm for a required bool, but takes free-text true/false for an optional *bool so an empty answer can mean skip. +func (b *Builder) assignBool(label string, v reflect.Value, optional bool, meta leafMeta) (bool, error) { + if !optional { + val, err := b.Prompter.Confirm(label, meta.promptOptions()...) if err != nil { return false, err } - if s == "" { - // Only reachable when optional (see the integer case above). - return false, nil - } - // Parse at the field's precision so a float32 overflow is caught here - // rather than later becoming +Inf, which encoding/json cannot marshal. - f, perr := strconv.ParseFloat(s, v.Type().Bits()) - if perr != nil { - return false, fmt.Errorf("invalid number for %s: %w", label, perr) - } - v.SetFloat(f) + v.SetBool(val) return true, nil - default: - return false, fmt.Errorf("interactive: unsupported scalar kind %s for %s", v.Kind(), label) } + s, err := b.Prompter.Input(label+" (true/false, empty to skip)", boolValidator(), meta.promptOptions()...) + if err != nil || s == "" { + return false, err + } + val, err := strconv.ParseBool(s) + if err != nil { + return false, fmt.Errorf("invalid boolean for %s: %w", label, err) + } + v.SetBool(val) + return true, nil +} + +// scalarHint is the suffix that tells the user what to type and whether an empty answer skips. +func scalarHint(kind string, optional bool) string { + if optional { + return " (" + kind + ", empty to skip)" + } + return " (" + kind + ")" +} + +// assignInt treats an empty answer as skip, which is only reachable when optional because intValidator rejects empty otherwise. +func (b *Builder) assignInt(label string, v reflect.Value, optional bool, meta leafMeta) (bool, error) { + bits := v.Type().Bits() + s, err := b.Prompter.Input(label+scalarHint("integer", optional), intValidator(label, bits, optional), meta.promptOptions()...) + if err != nil || s == "" { + return false, err + } + n, err := strconv.ParseInt(s, 10, bits) + if err != nil { + return false, fmt.Errorf("invalid integer for %s: %w", label, err) + } + v.SetInt(n) + return true, nil } -func (b *Builder) buildPointer(label string, rv reflect.Value, depth int, stack []reflect.Type) error { +// assignFloat parses at the field's precision so a float32 overflow fails here instead of becoming +Inf, which encoding/json cannot marshal. +func (b *Builder) assignFloat(label string, v reflect.Value, optional bool, meta leafMeta) (bool, error) { + bits := v.Type().Bits() + s, err := b.Prompter.Input(label+scalarHint("number", optional), floatValidator(label, bits, optional), meta.promptOptions()...) + if err != nil || s == "" { + return false, err + } + f, err := strconv.ParseFloat(s, bits) + if err != nil { + return false, fmt.Errorf("invalid number for %s: %w", label, err) + } + v.SetFloat(f) + return true, nil +} + +func (b *Builder) buildPointer(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { elem := rv.Type().Elem() switch elem.Kind() { case reflect.String, reflect.Bool, @@ -230,7 +359,7 @@ func (b *Builder) buildPointer(label string, rv reflect.Value, depth int, stack // Optional scalar: build into a fresh element and only assign the pointer // if the user provided a value. ptr := reflect.New(elem) - set, err := b.assignScalar(label, ptr.Elem(), true) + set, err := b.assignScalar(label, ptr.Elem(), true, meta) if err != nil || !set { return err } @@ -239,20 +368,26 @@ func (b *Builder) buildPointer(label string, rv reflect.Value, depth int, stack } // Pointer to struct, slice, map, or other composite. - set, err := b.Prompter.Confirm("Set " + label + "?") + set, err := b.Prompter.Confirm("Set "+label+"?", meta.promptOptions()...) if err != nil || !set { return err } + // The Confirm above already showed the description, so the prompts inside do not repeat it. + meta.help = "" ptr := reflect.New(elem) - if err := b.buildValue(label, ptr.Elem(), depth+1, stack); err != nil { + if err := b.buildValue(label, ptr.Elem(), depth+1, stack, meta); err != nil { return err } rv.Set(ptr) return nil } -func (b *Builder) promptRawJSON(label string, rv reflect.Value) error { - raw, err := b.Prompter.Input(label+" (raw JSON, empty to skip)", jsonValidator()) +func (b *Builder) promptRawJSON(label string, rv reflect.Value, meta leafMeta) error { + // Hand-written SDK examples are not always valid JSON, and a non-JSON default would block skipping. + if !json.Valid([]byte(meta.example)) { + meta.example = "" + } + raw, err := b.Prompter.Input(label+" (raw JSON, empty to skip)", jsonValidator(), meta.promptOptions()...) if err != nil || raw == "" { return err } @@ -265,16 +400,16 @@ func (b *Builder) promptRawJSON(label string, rv reflect.Value) error { return nil } -func (b *Builder) buildStruct(label string, rv reflect.Value, depth int, stack []reflect.Type) error { +func (b *Builder) buildStruct(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { t := rv.Type() if shouldSkipType(t) { return nil } if isUnionType(t) { - return b.buildUnion(label, rv, depth, stack) + return b.buildUnion(label, rv, depth, stack, meta) } if isParamBag(t, b.paramBagThreshold()) { - return b.buildParamBag(label, rv, depth, stack) + return b.buildParamBag(label, rv, depth, stack, meta) } stack = append(stack, t) for i := 0; i < t.NumField(); i++ { @@ -290,22 +425,23 @@ func (b *Builder) buildStruct(label string, rv reflect.Value, depth int, stack [ if !rv.Field(i).IsZero() { continue } - fieldLabel := label + "." + jsonFieldName(jsonTag) - if err := b.buildValue(fieldLabel, rv.Field(i), depth+1, stack); err != nil { + jsonName := jsonFieldName(jsonTag) + fieldLabel := label + "." + jsonName + if err := b.buildValue(fieldLabel, rv.Field(i), depth+1, stack, resolveFieldMeta(rv, jsonName)); err != nil { return err } } return nil } -func (b *Builder) buildUnion(label string, rv reflect.Value, depth int, stack []reflect.Type) error { - t := rv.Type() - type variant struct { - fieldIdx int - name string - } - var variants []variant - var labels []string +type unionVariant struct { + fieldIdx int + name string +} + +// unionVariants lists the pointer fields of a oneOf wrapper, named by their type. +func unionVariants(t reflect.Type) []unionVariant { + var variants []unionVariant for i := 0; i < t.NumField(); i++ { f := t.Field(i) if !f.IsExported() || f.Type.Kind() != reflect.Pointer { @@ -315,33 +451,52 @@ func (b *Builder) buildUnion(label string, rv reflect.Value, depth int, stack [] if name == "" { name = f.Name } - variants = append(variants, variant{i, name}) - labels = append(labels, name) + variants = append(variants, unionVariant{i, name}) } + return variants +} + +// variantLabel appends the variant's description when the owner exposes one through VariantDescription. +func variantLabel(owner reflect.Value, name string) string { + desc := callStringField(owner, "VariantDescription", name) + if desc == "" { + return name + } + return name + ": " + desc +} + +func (b *Builder) buildUnion(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { + variants := unionVariants(rv.Type()) if len(variants) == 0 { return nil } - pick, err := b.Prompter.Select(label+" (variant)", labels) + labels := make([]string, len(variants)) + for i, v := range variants { + labels[i] = variantLabel(rv, v.name) + } + pick, err := b.Prompter.Select(label+" (variant)", labels, WithHelp(meta.help)) if err != nil { return err } - for _, v := range variants { - if v.name != pick { - continue - } - field := rv.Field(v.fieldIdx) - ptr := reflect.New(field.Type().Elem()) - stack = append(stack, t) - if err := b.buildValue(label+"."+v.name, ptr.Elem(), depth+1, stack); err != nil { - return err - } - field.Set(ptr) + idx := slices.Index(labels, pick) + if idx < 0 { return nil } + return b.buildVariant(label, rv, variants[idx], depth, stack) +} + +func (b *Builder) buildVariant(label string, rv reflect.Value, v unionVariant, depth int, stack []reflect.Type) error { + field := rv.Field(v.fieldIdx) + ptr := reflect.New(field.Type().Elem()) + stack = append(stack, rv.Type()) + if err := b.buildValue(label+"."+v.name, ptr.Elem(), depth+1, stack, leafMeta{}); err != nil { + return err + } + field.Set(ptr) return nil } -func (b *Builder) buildParamBag(label string, rv reflect.Value, depth int, stack []reflect.Type) error { +func (b *Builder) buildParamBag(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { t := rv.Type() type entry struct { fieldIdx int @@ -375,7 +530,7 @@ func (b *Builder) buildParamBag(label string, rv reflect.Value, depth int, stack for i, e := range entries { options[i] = fmt.Sprintf("%s (%s)", e.jsonName, e.typeName) } - picked, err := b.Prompter.MultiSelect(label, options) + picked, err := b.Prompter.MultiSelect(label, options, WithHelp(meta.help)) if err != nil { return err } @@ -386,15 +541,15 @@ func (b *Builder) buildParamBag(label string, rv reflect.Value, depth int, stack } e := entries[idx] fieldLabel := label + "." + e.jsonName - if err := b.buildValue(fieldLabel, rv.Field(e.fieldIdx), depth+1, stack); err != nil { + if err := b.buildValue(fieldLabel, rv.Field(e.fieldIdx), depth+1, stack, resolveFieldMeta(rv, e.jsonName)); err != nil { return err } } return nil } -func (b *Builder) buildSlice(label string, rv reflect.Value, depth int, stack []reflect.Type) error { - count, err := b.inputInt("how many "+label, 32) +func (b *Builder) buildSlice(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { + count, err := b.inputInt("how many "+label, 32, WithHelp(meta.help)) if err != nil { return err } @@ -405,7 +560,7 @@ func (b *Builder) buildSlice(label string, rv reflect.Value, depth int, stack [] out := reflect.MakeSlice(t, int(count), int(count)) for i := 0; i < int(count); i++ { itemLabel := fmt.Sprintf("%s[%d]", label, i) - if err := b.buildValue(itemLabel, out.Index(i), depth+1, stack); err != nil { + if err := b.buildValue(itemLabel, out.Index(i), depth+1, stack, leafMeta{}); err != nil { return err } } @@ -413,12 +568,12 @@ func (b *Builder) buildSlice(label string, rv reflect.Value, depth int, stack [] return nil } -func (b *Builder) buildMap(label string, rv reflect.Value, depth int, stack []reflect.Type) error { +func (b *Builder) buildMap(label string, rv reflect.Value, depth int, stack []reflect.Type, meta leafMeta) error { t := rv.Type() if t.Key().Kind() != reflect.String { - return b.promptRawJSON(label, rv) + return b.promptRawJSON(label, rv, meta) } - count, err := b.inputInt("how many "+label+" entries", 32) + count, err := b.inputInt("how many "+label+" entries", 32, WithHelp(meta.help)) if err != nil { return err } @@ -445,7 +600,7 @@ func (b *Builder) buildMap(label string, rv reflect.Value, depth int, stack []re } } valPtr := reflect.New(valType) - if err := b.buildValue(fmt.Sprintf("%s[%q]", label, key), valPtr.Elem(), depth+1, stack); err != nil { + if err := b.buildValue(fmt.Sprintf("%s[%q]", label, key), valPtr.Elem(), depth+1, stack, leafMeta{}); err != nil { return err } out.SetMapIndex(reflect.ValueOf(key), valPtr.Elem()) diff --git a/pkg/interactive/builder_meta_test.go b/pkg/interactive/builder_meta_test.go new file mode 100644 index 00000000..eaab4969 --- /dev/null +++ b/pkg/interactive/builder_meta_test.go @@ -0,0 +1,280 @@ +package interactive + +import ( + "testing" + + "github.com/algolia/algoliasearch-client-go/v4/algolia/composition" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// promptCall is one prompt the builder issued. +type promptCall struct { + kind string + label string + cfg PromptConfig +} + +// recordingPrompter records every prompt and answers with the first option or an empty value. +type recordingPrompter struct { + calls []promptCall + confirmYes bool +} + +func (r *recordingPrompter) record(kind, label string, opts []PromptOption) { + r.calls = append(r.calls, promptCall{kind, label, ApplyPromptOptions(opts)}) +} + +func (r *recordingPrompter) Input(label string, _ func(string) error, opts ...PromptOption) (string, error) { + r.record("input", label, opts) + return "", nil +} + +func (r *recordingPrompter) Confirm(label string, opts ...PromptOption) (bool, error) { + r.record("confirm", label, opts) + return r.confirmYes, nil +} + +func (r *recordingPrompter) Select(label string, options []string, opts ...PromptOption) (string, error) { + r.record("select", label, opts) + return options[0], nil +} + +func (r *recordingPrompter) MultiSelect(label string, _ []string, opts ...PromptOption) ([]int, error) { + r.record("multiselect", label, opts) + return nil, nil +} + +func (r *recordingPrompter) findCall(kind, label string) (promptCall, bool) { + for _, c := range r.calls { + if c.kind == kind && c.label == label { + return c, true + } + } + return promptCall{}, false +} + +// wrongValuesEnum has a Values() list of the wrong element type. +type wrongValuesEnum string + +func (wrongValuesEnum) IsValid() bool { return true } +func (wrongValuesEnum) Values() []string { return []string{"a"} } + +// wrongAccessor has a FieldDescription whose parameter is a named string type. +type wrongAccessor struct { + Name string `json:"name"` +} + +func (wrongAccessor) FieldDescription(_ wrongValuesEnum) string { return "never used" } + +// describedUnion mimics a generated oneOf wrapper that exposes per-variant descriptions. +type describedUnion struct { + AsString *string `json:"-"` + AsNumber *int32 `json:"-"` +} + +func (describedUnion) VariantDescription(variant string) string { + if variant == "string" { + return "free text" + } + return "" +} + +// metaHolder mimics a generated SDK model with one field per prompt path that carries help. +type metaHolder struct { + Tone tone `json:"tone"` + Alive bool `json:"alive"` + Choice describedUnion `json:"choice"` + Pick *describedUnion `json:"pick,omitempty"` + Bag paramBag `json:"bag"` + Any any `json:"any"` + Bad any `json:"bad"` + Keyed map[int]string `json:"keyed"` + Odd wrongValuesEnum `json:"odd"` + Inner wrongAccessor `json:"inner"` +} + +var metaExamples = map[string]string{ + "tone": "cool", + "any": `"x"`, + "bad": "not json", + "keyed": `"x"`, +} + +func (metaHolder) FieldDescription(field string) string { return "describes " + field } + +func (metaHolder) FieldExample(field string) string { return metaExamples[field] } + +func TestBuild_FieldMetaReachesPrompts(t *testing.T) { + // Initial setup + rec := &recordingPrompter{confirmYes: true} + b := &Builder{Prompter: rec, ParamBagThreshold: 2} + require.NoError(t, b.Build(&metaHolder{})) + + // Define testcases + tests := []struct { + name string + kind string + label string + help string + def string + }{ + { + name: "enum select preselects the example", + kind: "select", + label: "metaHolder.tone", + help: "describes tone", + def: "cool", + }, + { + name: "bool confirm", + kind: "confirm", + label: "metaHolder.alive", + help: "describes alive", + }, + { + name: "union variant select", + kind: "select", + label: "metaHolder.choice (variant)", + help: "describes choice", + }, + { + name: "param bag multiselect", + kind: "multiselect", + label: "metaHolder.bag", + help: "describes bag", + }, + { + name: "pointer field shows help on the confirm", + kind: "confirm", + label: "Set metaHolder.pick?", + help: "describes pick", + }, + { + name: "pointer field does not repeat help on the inner prompt", + kind: "select", + label: "metaHolder.pick (variant)", + }, + { + name: "raw JSON keeps a valid JSON example", + kind: "input", + label: "metaHolder.any (raw JSON, empty to skip)", + help: "describes any", + def: `"x"`, + }, + { + name: "raw JSON drops an example that is not JSON", + kind: "input", + label: "metaHolder.bad (raw JSON, empty to skip)", + help: "describes bad", + }, + { + name: "non-string-keyed map falls back to raw JSON", + kind: "input", + label: "metaHolder.keyed (raw JSON, empty to skip)", + help: "describes keyed", + def: `"x"`, + }, + { + name: "Values with the wrong element type falls back to a text input", + kind: "input", + label: "metaHolder.odd", + help: "describes odd", + }, + { + name: "FieldDescription with a named string parameter yields no help", + kind: "input", + label: "metaHolder.inner.name", + }, + } + + // For each testcase + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Action + call, found := rec.findCall(tc.kind, tc.label) + + // Assert + require.True(t, found, "no %s prompt labelled %q", tc.kind, tc.label) + assert.Equal(t, tc.help, call.cfg.Help) + assert.Equal(t, tc.def, call.cfg.Default) + }) + } +} + +func TestBuild_UnionPickMapsDescribedLabelBackToVariant(t *testing.T) { + // Initial setup + str := "hello" + num := int32(7) + + // Define testcases + tests := []struct { + name string + pick string + in string + want describedUnion + }{ + { + name: "variant with a description", + pick: "string: free text", + in: "hello", + want: describedUnion{AsString: &str}, + }, + { + name: "variant without a description", + pick: "int32", + in: "7", + want: describedUnion{AsNumber: &num}, + }, + } + + // For each testcase + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup + b := &Builder{Prompter: &ScriptedPrompter{ + Selects: map[string]string{"variant": tc.pick}, + Inputs: map[string]string{"describedUnion.": tc.in}, + }} + var v describedUnion + + // Action + require.NoError(t, b.Build(&v)) + + // Assert + assert.Equal(t, tc.want, v) + }) + } +} + +func TestBuild_SDKMetadataAndSuggestReachPrompt(t *testing.T) { + // Setup + rec := &recordingPrompter{} + b := &Builder{ + Prompter: rec, + Suggest: func(_, _ string, _ map[string]string) []string { + return []string{"idx_alpha"} + }, + } + + // Action + require.NoError(t, b.Build(&composition.Composition{})) + + // Assert + call, found := rec.findCall("input", "Composition.name") + require.True(t, found, "no input prompt labelled Composition.name") + assert.NotEmpty(t, call.cfg.Help) + assert.NotEmpty(t, call.cfg.Default) + require.NotNil(t, call.cfg.Suggest) + assert.Equal(t, []string{"idx_alpha", call.cfg.Default}, call.cfg.Suggest("")) + + // Action (no Suggest) + bare := &recordingPrompter{} + require.NoError(t, (&Builder{Prompter: bare}).Build(&composition.Composition{})) + + // Assert (example is the only candidate) + bareCall, found := bare.findCall("input", "Composition.name") + require.True(t, found) + require.NotNil(t, bareCall.cfg.Suggest) + assert.Equal(t, []string{bareCall.cfg.Default}, bareCall.cfg.Suggest("")) +} diff --git a/pkg/interactive/builder_test.go b/pkg/interactive/builder_test.go index 3b8374ab..75b68838 100644 --- a/pkg/interactive/builder_test.go +++ b/pkg/interactive/builder_test.go @@ -141,6 +141,31 @@ func TestBuild_EnumRejectsInvalid(t *testing.T) { assert.Contains(t, err.Error(), "not a valid shade") } +// tone mimics a real SDK enum: IsValid plus a generated Values() list. +type tone string + +func (t tone) IsValid() bool { return t == "warm" || t == "cool" } +func (t tone) Values() []tone { return []tone{"warm", "cool"} } + +type toneHolder struct { + Tone tone `json:"tone"` + Opt *tone `json:"opt,omitempty"` +} + +func TestBuild_EnumSelect(t *testing.T) { + // Setup + b := &Builder{Prompter: &ScriptedPrompter{Selects: map[string]string{"tone": "cool", "toneHolder.opt": enumSkip}}} + var v toneHolder + + // Action + err := b.Build(&v) + + // Assert + require.NoError(t, err) + assert.Equal(t, tone("cool"), v.Tone) + assert.Nil(t, v.Opt) +} + type address struct { City string `json:"city"` } @@ -258,10 +283,17 @@ func TestBuild_ParamBag(t *testing.T) { type errPrompter struct{ err error } -func (e errPrompter) Input(string, func(string) error) (string, error) { return "", e.err } -func (e errPrompter) Confirm(string) (bool, error) { return false, e.err } -func (e errPrompter) Select(string, []string) (string, error) { return "", e.err } -func (e errPrompter) MultiSelect(string, []string) ([]int, error) { return nil, e.err } +func (e errPrompter) Input(string, func(string) error, ...PromptOption) (string, error) { + return "", e.err +} +func (e errPrompter) Confirm(string, ...PromptOption) (bool, error) { return false, e.err } +func (e errPrompter) Select(string, []string, ...PromptOption) (string, error) { + return "", e.err +} + +func (e errPrompter) MultiSelect(string, []string, ...PromptOption) ([]int, error) { + return nil, e.err +} func TestBuild_PrompterErrorPropagates(t *testing.T) { sentinel := errors.New("prompt failed") diff --git a/pkg/interactive/prompter.go b/pkg/interactive/prompter.go index e2a3f336..9e5f9d20 100644 --- a/pkg/interactive/prompter.go +++ b/pkg/interactive/prompter.go @@ -4,7 +4,9 @@ package interactive import ( + "fmt" "os" + "slices" "github.com/AlecAivazis/survey/v2" @@ -12,22 +14,53 @@ import ( "github.com/algolia/cli/pkg/prompt" ) +// PromptConfig holds the optional help, default and completion of a prompt, and each prompt kind ignores the fields it cannot use. +type PromptConfig struct { + Help string + Default string + Suggest func(toComplete string) []string +} + +// PromptOption sets one field of a PromptConfig. +type PromptOption func(*PromptConfig) + +// WithHelp sets the description printed above the prompt. +func WithHelp(help string) PromptOption { + return func(c *PromptConfig) { c.Help = help } +} + +// WithDefault sets the value used when the user submits an empty line. +func WithDefault(def string) PromptOption { + return func(c *PromptConfig) { c.Default = def } +} + +// WithSuggest sets the tab-completion callback, and nil disables completion. +func WithSuggest(suggest func(toComplete string) []string) PromptOption { + return func(c *PromptConfig) { c.Suggest = suggest } +} + +// ApplyPromptOptions folds opts into a PromptConfig, in order, so a later option overrides an earlier one. +func ApplyPromptOptions(opts []PromptOption) PromptConfig { + var c PromptConfig + for _, opt := range opts { + opt(&c) + } + return c +} + // Prompter is the input surface used by the reflective Builder. Production code // uses SurveyPrompter; tests use a scripted fake. type Prompter interface { - // Input reads a free-text line. validate, when non-nil, is run on the entry; - // the implementation re-prompts until it passes (SurveyPrompter) or surfaces - // its error (ScriptedPrompter). Pass nil for no validation. An empty entry - // means "skip" for optional fields and zero for required scalars. - Input(label string, validate func(string) error) (string, error) + // Input re-prompts (SurveyPrompter) or returns the error (ScriptedPrompter) when validate fails, and an empty entry means skip. + Input(label string, validate func(string) error, opts ...PromptOption) (string, error) // Confirm asks a yes/no question. - Confirm(label string) (bool, error) + Confirm(label string, opts ...PromptOption) (bool, error) // Select asks the user to pick exactly one option and returns the chosen // label. - Select(label string, options []string) (string, error) + Select(label string, options []string, opts ...PromptOption) (string, error) // MultiSelect asks the user to pick zero or more options and returns the // chosen 0-based indexes. - MultiSelect(label string, options []string) ([]int, error) + MultiSelect(label string, options []string, opts ...PromptOption) ([]int, error) } // SurveyPrompter implements Prompter using survey/v2 via the repo's pkg/prompt @@ -51,7 +84,7 @@ func (s *SurveyPrompter) surveyOpts() []survey.AskOpt { } } -func (s *SurveyPrompter) Input(label string, validate func(string) error) (string, error) { +func (s *SurveyPrompter) Input(label string, validate func(string) error, promptOpts ...PromptOption) (string, error) { var out string opts := s.surveyOpts() if validate != nil { @@ -65,28 +98,50 @@ func (s *SurveyPrompter) Input(label string, validate func(string) error) (strin return validate(str) })) } - err := prompt.SurveyAskOne(&survey.Input{Message: label}, &out, opts...) + cfg := s.printHelp(promptOpts) + err := prompt.SurveyAskOne(&survey.Input{Message: label, Default: cfg.Default, Suggest: cfg.Suggest}, &out, opts...) return out, err } -func (s *SurveyPrompter) Confirm(label string) (bool, error) { +func (s *SurveyPrompter) Confirm(label string, promptOpts ...PromptOption) (bool, error) { var out bool + s.printHelp(promptOpts) err := prompt.SurveyAskOne(&survey.Confirm{Message: label}, &out, s.surveyOpts()...) return out, err } -func (s *SurveyPrompter) Select(label string, options []string) (string, error) { +func (s *SurveyPrompter) Select(label string, options []string, promptOpts ...PromptOption) (string, error) { var out string - err := prompt.SurveyAskOne(&survey.Select{Message: label, Options: options}, &out, s.surveyOpts()...) + cfg := s.printHelp(promptOpts) + err := prompt.SurveyAskOne(&survey.Select{Message: label, Options: options, Default: selectDefault(options, cfg.Default)}, &out, s.surveyOpts()...) return out, err } -func (s *SurveyPrompter) MultiSelect(label string, options []string) ([]int, error) { +func (s *SurveyPrompter) MultiSelect(label string, options []string, promptOpts ...PromptOption) ([]int, error) { var out []int + s.printHelp(promptOpts) err := prompt.SurveyAskOne(&survey.MultiSelect{Message: label, Options: options}, &out, s.surveyOpts()...) return out, err } +// printHelp prints the description above the prompt because survey's own Help only adds a "[? for help]" hint. +func (s *SurveyPrompter) printHelp(promptOpts []PromptOption) PromptConfig { + cfg := ApplyPromptOptions(promptOpts) + if cfg.Help != "" { + cs := s.io.ColorScheme() + fmt.Fprintln(s.io.Out, cs.Gray(cfg.Help)) + } + return cfg +} + +// selectDefault returns the preselected option, or nil when def is not one of the options because survey rejects an unknown default. +func selectDefault(options []string, def string) any { + if !slices.Contains(options, def) { + return nil + } + return def +} + // fileReader/fileWriter adapt iostreams to survey's terminal file interfaces. // Fd() returns the real stdio descriptor so survey can toggle raw mode on a // genuine TTY; in tests the prompt vars are stubbed so Fd is never used. diff --git a/pkg/interactive/prompter_test.go b/pkg/interactive/prompter_test.go index 6269a098..7aa3cc16 100644 --- a/pkg/interactive/prompter_test.go +++ b/pkg/interactive/prompter_test.go @@ -50,3 +50,73 @@ func TestSurveyPrompter_UsesPromptVars(t *testing.T) { require.NoError(t, err) assert.Equal(t, []int{1}, multi) } + +// stubSurveyAskOne replaces survey with a no-op and returns a pointer to the last prompt it received. +func stubSurveyAskOne(t *testing.T) *survey.Prompt { + t.Helper() + orig := prompt.SurveyAskOne + t.Cleanup(func() { + prompt.SurveyAskOne = orig + }) + var last survey.Prompt + prompt.SurveyAskOne = func(sp survey.Prompt, _ interface{}, _ ...survey.AskOpt) error { + last = sp + return nil + } + return &last +} + +func TestSurveyPrompter_PrintsHelpForEveryKind(t *testing.T) { + // Setup + io, _, out, _ := iostreams.Test() + stubSurveyAskOne(t) + p := NewSurveyPrompter(io) + + // Action + _, _ = p.Input("x", nil, WithHelp("input help")) + _, _ = p.Confirm("x", WithHelp("confirm help")) + _, _ = p.Select("x", []string{"a"}, WithHelp("select help")) + _, _ = p.MultiSelect("x", []string{"a"}, WithHelp("multiselect help")) + + // Assert + assert.Contains(t, out.String(), "input help") + assert.Contains(t, out.String(), "confirm help") + assert.Contains(t, out.String(), "select help") + assert.Contains(t, out.String(), "multiselect help") +} + +func TestSurveyPrompter_SelectPreselectsDefault(t *testing.T) { + // Initial setup + io, _, _, _ := iostreams.Test() + last := stubSurveyAskOne(t) + + // Define testcases + tests := []struct { + name string + def string + want any + }{ + { + name: "default is an option", + def: "b", + want: "b", + }, + { + name: "default is not an option", + def: "z", + want: nil, + }, + } + + // For each testcase + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Action + _, err := NewSurveyPrompter(io).Select("x", []string{"a", "b"}, WithDefault(tc.def)) + + // Assert + require.NoError(t, err) + assert.Equal(t, tc.want, (*last).(*survey.Select).Default) + }) + } +} diff --git a/pkg/interactive/scripted_prompter.go b/pkg/interactive/scripted_prompter.go index 66fe25f6..b2522626 100644 --- a/pkg/interactive/scripted_prompter.go +++ b/pkg/interactive/scripted_prompter.go @@ -22,7 +22,7 @@ type ScriptedPrompter struct { var _ Prompter = (*ScriptedPrompter)(nil) -func (p *ScriptedPrompter) Input(label string, validate func(string) error) (string, error) { +func (p *ScriptedPrompter) Input(label string, validate func(string) error, _ ...PromptOption) (string, error) { v, _ := lookup(p.Inputs, label) // Run the validator on the scripted answer so invalid scripted data surfaces // deterministically (no infinite re-prompt loop). In production survey would @@ -35,14 +35,14 @@ func (p *ScriptedPrompter) Input(label string, validate func(string) error) (str return v, nil } -func (p *ScriptedPrompter) Confirm(label string) (bool, error) { +func (p *ScriptedPrompter) Confirm(label string, _ ...PromptOption) (bool, error) { if v, ok := lookup(p.Confirms, label); ok { return v, nil } return false, nil } -func (p *ScriptedPrompter) Select(label string, options []string) (string, error) { +func (p *ScriptedPrompter) Select(label string, options []string, _ ...PromptOption) (string, error) { if v, ok := lookup(p.Selects, label); ok { return v, nil } @@ -52,7 +52,7 @@ func (p *ScriptedPrompter) Select(label string, options []string) (string, error return "", nil } -func (p *ScriptedPrompter) MultiSelect(label string, options []string) ([]int, error) { +func (p *ScriptedPrompter) MultiSelect(label string, options []string, _ ...PromptOption) ([]int, error) { if v, ok := lookup(p.MultiSelects, label); ok { return v, nil }