Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow prompt to have a default answer. #156

Merged
merged 6 commits into from
Oct 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
})
}
}
Loading