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 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 }