Skip to content

Commit

Permalink
Allow prompt to have a default answer. (#156)
Browse files Browse the repository at this point in the history
  • Loading branch information
Gerrit91 authored Oct 16, 2024
1 parent 0ef48f0 commit 100f7e5
Show file tree
Hide file tree
Showing 2 changed files with 160 additions and 13 deletions.
81 changes: 68 additions & 13 deletions pkg/genericcli/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,26 @@ import (
"fmt"
"io"
"os"
"slices"
"strings"
"unicode"

"github.com/metal-stack/metal-lib/pkg/pointer"
)

type PromptConfig struct {
Message string
No string
// Message is a message shown by the prompt before the input prompt
Message string
// ShowAnswers shows the accepted answers when set to true
ShowAnswers bool
// AcceptedAnswers contains the accepted answers to make the prompt succeed
AcceptedAnswers []string
ShowAnswers bool
In io.Reader
Out io.Writer
// DefaultAnswer is an optional prompt configuration that uses this answer in case the input closes without any content, it needs to be contained in the list of accepted answers or needs to be the "no" answer
DefaultAnswer string
// No is shown in addition to the accepted answers, can be empty
No string
In io.Reader
Out io.Writer
}

func PromptDefaultQuestion() string {
Expand All @@ -27,24 +35,32 @@ func PromptDefaultAnswers() []string {
return []string{"y", "yes"}
}

// Prompt the user to given compare text
func Prompt() error {
return PromptCustom(&PromptConfig{
func promptDefaultConfig() *PromptConfig {
return &PromptConfig{
Message: PromptDefaultQuestion(),
No: "n",
AcceptedAnswers: PromptDefaultAnswers(),
ShowAnswers: true,
})
}
}

// Prompt the user to given compare text
func Prompt() error {
return PromptCustom(promptDefaultConfig())
}

// PromptCustomAnswers the user to given compare text
// "no" can be an empty string, "yes" is the list of accepted yes answers.
func PromptCustom(c *PromptConfig) error {
if c == nil {
c = promptDefaultConfig()
}
if c.Message == "" {
panic("internal error: prompt not properly configured")
c.Message = PromptDefaultQuestion()
}
if len(c.AcceptedAnswers) == 0 {
c.AcceptedAnswers = PromptDefaultAnswers()
c.DefaultAnswer = pointer.FirstOrZero(c.AcceptedAnswers)
c.No = "n"
}
if c.In == nil {
c.In = os.Stdin
Expand All @@ -53,11 +69,45 @@ func PromptCustom(c *PromptConfig) error {
c.Out = os.Stdout
}

// validate, we need to panic here because this is really a configuration error and code execution needs to stop
for _, answer := range c.AcceptedAnswers {
if len(answer) == 0 {
panic("configured prompt answer must not be an empty string")
}
}

defaultAnswerIndex := slices.IndexFunc(c.AcceptedAnswers, func(answer string) bool {
return answer == c.DefaultAnswer
})

if c.DefaultAnswer != "" {
if defaultAnswerIndex < 0 && c.DefaultAnswer != c.No {
panic("configured prompt default answer must be contained in accepted answer or no answer")
}
}

if c.ShowAnswers {
sentenceCase := func(s string) string {
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}

no := c.No
yes := pointer.FirstOrZero(c.AcceptedAnswers)

if c.DefaultAnswer != "" {
if c.DefaultAnswer == c.No {
no = sentenceCase(c.No)
} else {
yes = sentenceCase(c.AcceptedAnswers[defaultAnswerIndex])
}
}

if c.No == "" {
fmt.Fprintf(c.Out, "%s [%s] ", c.Message, pointer.FirstOrZero(c.AcceptedAnswers))
fmt.Fprintf(c.Out, "%s [%s] ", c.Message, yes)
} else {
fmt.Fprintf(c.Out, "%s [%s/%s] ", c.Message, pointer.FirstOrZero(c.AcceptedAnswers), c.No)
fmt.Fprintf(c.Out, "%s [%s/%s] ", c.Message, yes, no)
}
} else {
fmt.Fprintf(c.Out, "%s ", c.Message)
Expand All @@ -70,6 +120,11 @@ func PromptCustom(c *PromptConfig) error {
}

text := scanner.Text()

if text == "" {
text = c.DefaultAnswer
}

for _, accepted := range c.AcceptedAnswers {
if strings.EqualFold(text, accepted) {
return nil
Expand Down
92 changes: 92 additions & 0 deletions pkg/genericcli/prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package genericcli

import (
"bytes"
"fmt"
"testing"

"github.com/google/go-cmp/cmp"
"github.com/metal-stack/metal-lib/pkg/testcommon"
)

func TestPromptCustom(t *testing.T) {
tests := []struct {
name string
c *PromptConfig
input string
want string
wantErr error
}{
{
name: "default prompt config answered with yes",
input: "yes\n",
want: "Do you want to continue? [y/n] ",
},
{
name: "default prompt config answered with no",
input: "no\n",
want: "Do you want to continue? [y/n] ",
wantErr: fmt.Errorf(`aborting due to given answer ("no")`),
},
{
name: "custom prompt config",
input: "ack\n",
c: &PromptConfig{
Message: "Do you get it?",
ShowAnswers: true,
AcceptedAnswers: []string{"ack", "a"},
DefaultAnswer: "ack",
No: "nack",
},
want: "Do you get it? [Ack/nack] ",
},
{
name: "custom prompt config, default answer with empty input",
input: "\n",
c: &PromptConfig{
Message: "Do you get it?",
ShowAnswers: true,
AcceptedAnswers: []string{"ack", "a"},
DefaultAnswer: "ack",
No: "nack",
},
want: "Do you get it? [Ack/nack] ",
},
{
name: "custom prompt config, default is no answer",
input: "ack\n",
c: &PromptConfig{
Message: "Do you get it?",
ShowAnswers: true,
AcceptedAnswers: []string{"ack", "a"},
DefaultAnswer: "nack",
No: "nack",
},
want: "Do you get it? [ack/Nack] ",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var (
in bytes.Buffer
out bytes.Buffer
)

if tt.c == nil {
tt.c = promptDefaultConfig()
}
tt.c.In = &in
tt.c.Out = &out

in.WriteString(tt.input)

err := PromptCustom(tt.c)
if diff := cmp.Diff(tt.wantErr, err, testcommon.ErrorStringComparer()); diff != "" {
t.Errorf("error diff (+got -want):\n %s", diff)
}
if diff := cmp.Diff(tt.want, out.String()); diff != "" {
t.Errorf("diff (+got -want):\n %s", diff)
}
})
}
}

0 comments on commit 100f7e5

Please sign in to comment.