Skip to content

Commit

Permalink
Fix manual input should support bools (#203)
Browse files Browse the repository at this point in the history
* WIP fix prompt for inputs

* Handle lists and maps in manual input. Exit on unknown type with no default value

* Extract validation and test basics
  • Loading branch information
Resonance1584 authored Dec 11, 2024
1 parent 9f2052c commit 05b2842
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 41 deletions.
100 changes: 60 additions & 40 deletions config/get_variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"log"
"sort"
"strconv"

"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
Expand All @@ -15,7 +14,6 @@ import (
"github.com/gruntwork-io/boilerplate/util"
"github.com/gruntwork-io/boilerplate/variables"
"github.com/hashicorp/go-multierror"
"github.com/inancgumus/screen"
"github.com/pterm/pterm"
)

Expand Down Expand Up @@ -195,22 +193,54 @@ func getVariableFromVars(variable variables.Variable, opts *options.BoilerplateO

// Get the value for the given variable by prompting the user
func getVariableFromUser(variable variables.Variable, opts *options.BoilerplateOptions, invalidEntries variables.InvalidEntries) (interface{}, error) {
// Start by clearing any previous contents
screen.Clear()

// Add a newline for legibility and padding
fmt.Println()

// Show the current variable's name, description, and also render any validation errors in real-time so the user knows what's wrong
// with their input
renderVariablePrompts(variable, invalidEntries)

value := ""
value, err := getUserInput(variable)
if err != nil {
return value, err
}
// If any of the variable's validation rules are not satisfied by the user's submission,
// store the validation errors in a map. We'll then recursively call get_variable_from_user
// again, this time passing in the validation errors map, so that we can render to the terminal
// the exact issues with each submission
validationMap, hasValidationErrs := validateUserInput(value, variable)
if hasValidationErrs {
ie := variables.InvalidEntries{
Issues: []variables.ValidationIssue{
{
Value: value,
ValidationMap: validationMap,
},
},
}
return getVariableFromUser(variable, opts, ie)
}

if value == "" {
// TODO: what if the user wanted an empty string instead of the default?
util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default())
return variable.Default(), nil
}

return value, nil
}

func getUserInput(variable variables.Variable) (string, error) {
// Display rich prompts to the user, based on the type of variable we're asking for
value := ""
switch variable.Type() {
case variables.String:
case variables.String, variables.Int, variables.Float, variables.Bool, variables.List, variables.Map:
msg := fmt.Sprintf("Enter a value [type %s]", variable.Type())
if variable.Default() != nil {
msg = fmt.Sprintf("%s (default: %v)", msg, variable.Default())
}
prompt := &survey.Input{
Message: fmt.Sprintf("Please enter %s", variable.FullName()),
Message: msg,
}
err := survey.AskOne(prompt, &value)
if err != nil {
Expand All @@ -231,18 +261,26 @@ func getVariableFromUser(variable variables.Variable, opts *options.BoilerplateO
}
return value, err
}
default:
if variable.Default() == nil {
fmt.Println()
msg := fmt.Sprintf("Variable %s of type '%s' does not support manual input and has no default value.\n"+
"Please update the variable in the boilerplate.yml file to include a default value or provide a value via the command line using the --var option.",
pterm.Green(variable.FullName()), variable.Type())
log.Fatal(msg)
}
}
return value, nil
}

func validateUserInput(value string, variable variables.Variable) (map[string]bool, bool) {
var valueToValidate interface{}
if value == "" {
valueToValidate = variable.Default()
} else {
valueToValidate = value
}
// If any of the variable's validation rules are not satisfied by the user's submission,
// store the validation errors in a map. We'll then recursively call get_variable_from_user
// again, this time passing in the validation errors map, so that we can render to the terminal
// the exact issues with each submission

m := make(map[string]bool)
hasValidationErrs := false
for _, customValidation := range variable.Validations() {
Expand All @@ -255,41 +293,23 @@ func getVariableFromUser(variable variables.Variable, opts *options.BoilerplateO
}
m[customValidation.DescriptionText()] = val
}
if hasValidationErrs {
ie := variables.InvalidEntries{
Issues: []variables.ValidationIssue{
{
Value: value,
ValidationMap: m,
},
},
}
return getVariableFromUser(variable, opts, ie)
}

if value == "" {
// TODO: what if the user wanted an empty string instead of the default?
util.Logger.Printf("Using default value for variable '%s': %v", variable.FullName(), variable.Default())
return variable.Default(), nil
// Validate that the type can be parsed
if _, err := variables.ConvertType(valueToValidate, variable); err != nil {
hasValidationErrs = true
msg := fmt.Sprintf("Value must be of type %s: %s", variable.Type(), err)
m[msg] = false
}

// Clear the terminal of all previous text for legibility
util.ClearTerminal()

if variable.Type() == variables.String {
_, intErr := strconv.Atoi(value)
if intErr == nil {
value = fmt.Sprintf(`"%s"`, value)
}
// Validate that the value is not empty if no default is provided
if value == "" && variable.Default() == nil {
hasValidationErrs = true
m["Value must be provided"] = false
}

return variables.ParseYamlString(value)
return m, hasValidationErrs
}

// RenderValidationErrors displays in user-legible format the exact validation errors
// that the user's last submission generated
func renderValidationErrors(val interface{}, m map[string]bool) {
util.ClearTerminal()
pterm.Warning.WithPrefix(pterm.Prefix{Text: "Invalid entry"}).Println(val)
for k, v := range m {
if v {
Expand Down
31 changes: 31 additions & 0 deletions config/get_variables_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"reflect"
"testing"

"golang.org/x/exp/maps"

"github.com/stretchr/testify/assert"

"github.com/gruntwork-io/boilerplate/errors"
Expand Down Expand Up @@ -262,3 +264,32 @@ func TestGetVariablesMatchFromVarsAndDefaults(t *testing.T) {
assert.Nil(t, err)
assert.Equal(t, expected, actual)
}

func TestValidateUserInput(t *testing.T) {
t.Parallel()

// An empty variable with no default value should fail validation
v := variables.NewStringVariable("foo")
m, hasValidationErrs := validateUserInput("", v)
assert.True(t, hasValidationErrs)
assert.Equal(t, map[string]bool{"Value must be provided": false}, m)

// An empty variable with a default value should pass validation
v = variables.NewStringVariable("foo").WithDefault("bar")
m, hasValidationErrs = validateUserInput("", v)
assert.False(t, hasValidationErrs)
assert.Empty(t, m)

// A non-empty variable should pass validation
v = variables.NewStringVariable("foo")
m, hasValidationErrs = validateUserInput("bar", v)
assert.False(t, hasValidationErrs)
assert.Empty(t, m)

// A variable that cannot be parsed should fail validation
v = variables.NewIntVariable("foo")
m, hasValidationErrs = validateUserInput("bar", v)
assert.True(t, hasValidationErrs)
key := maps.Keys(m)[0]
assert.Contains(t, key, "Value must be of type int")
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ require (
require (
github.com/gabriel-vasile/mimetype v1.4.6
github.com/google/go-cmp v0.6.0
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
)

require (
Expand Down Expand Up @@ -112,7 +113,6 @@ require (
go.opentelemetry.io/otel/sdk v1.31.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.31.0 // indirect
go.opentelemetry.io/otel/trace v1.31.0 // indirect
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c // indirect
golang.org/x/net v0.30.0 // indirect
golang.org/x/oauth2 v0.23.0 // indirect
golang.org/x/sync v0.8.0 // indirect
Expand Down

0 comments on commit 05b2842

Please sign in to comment.