diff --git a/pkg/genericcli/prompt.go b/pkg/genericcli/prompt.go index e7477a6..a01132d 100644 --- a/pkg/genericcli/prompt.go +++ b/pkg/genericcli/prompt.go @@ -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 { @@ -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 @@ -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) @@ -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 diff --git a/pkg/genericcli/prompt_test.go b/pkg/genericcli/prompt_test.go new file mode 100644 index 0000000..6d27baf --- /dev/null +++ b/pkg/genericcli/prompt_test.go @@ -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) + } + }) + } +}