Adding upstream version 0.8.9.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
3b2c48b5e4
commit
c0c4addb85
285 changed files with 25880 additions and 0 deletions
233
pkg/util/generator/generator_common.go
Normal file
233
pkg/util/generator/generator_common.go
Normal file
|
@ -0,0 +1,233 @@
|
|||
package generator
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/format"
|
||||
)
|
||||
|
||||
// errInvalidFormat indicates an invalid user input format.
|
||||
var (
|
||||
errInvalidFormat = errors.New("invalid format")
|
||||
errRequired = errors.New("field is required")
|
||||
errNotANumber = errors.New("not a number")
|
||||
errInvalidBoolFormat = errors.New("answer must be yes or no")
|
||||
)
|
||||
|
||||
// ValidateFormat wraps a boolean validator to return an error on false results.
|
||||
func ValidateFormat(validator func(string) bool) func(string) error {
|
||||
return func(answer string) error {
|
||||
if validator(answer) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errInvalidFormat
|
||||
}
|
||||
}
|
||||
|
||||
// Required validates that the input contains at least one character.
|
||||
func Required(answer string) error {
|
||||
if answer == "" {
|
||||
return errRequired
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UserDialog facilitates question/answer-based user interaction.
|
||||
type UserDialog struct {
|
||||
reader io.Reader
|
||||
writer io.Writer
|
||||
scanner *bufio.Scanner
|
||||
props map[string]string
|
||||
}
|
||||
|
||||
// NewUserDialog initializes a UserDialog with safe defaults.
|
||||
func NewUserDialog(reader io.Reader, writer io.Writer, props map[string]string) *UserDialog {
|
||||
if props == nil {
|
||||
props = map[string]string{}
|
||||
}
|
||||
|
||||
return &UserDialog{
|
||||
reader: reader,
|
||||
writer: writer,
|
||||
scanner: bufio.NewScanner(reader),
|
||||
props: props,
|
||||
}
|
||||
}
|
||||
|
||||
// Write sends a message to the user.
|
||||
func (ud *UserDialog) Write(message string, v ...any) {
|
||||
if _, err := fmt.Fprintf(ud.writer, message, v...); err != nil {
|
||||
_, _ = fmt.Fprint(ud.writer, "failed to write to output: ", err, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Writelnf writes a formatted message to the user, completing a line.
|
||||
func (ud *UserDialog) Writelnf(format string, v ...any) {
|
||||
ud.Write(format+"\n", v...)
|
||||
}
|
||||
|
||||
// Query prompts the user and returns regex groups if the input matches the validator pattern.
|
||||
func (ud *UserDialog) Query(prompt string, validator *regexp.Regexp, key string) []string {
|
||||
var groups []string
|
||||
|
||||
ud.QueryString(prompt, ValidateFormat(func(answer string) bool {
|
||||
groups = validator.FindStringSubmatch(answer)
|
||||
|
||||
return groups != nil
|
||||
}), key)
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
// QueryAll prompts the user and returns multiple regex matches up to maxMatches.
|
||||
func (ud *UserDialog) QueryAll(
|
||||
prompt string,
|
||||
validator *regexp.Regexp,
|
||||
key string,
|
||||
maxMatches int,
|
||||
) [][]string {
|
||||
var matches [][]string
|
||||
|
||||
ud.QueryString(prompt, ValidateFormat(func(answer string) bool {
|
||||
matches = validator.FindAllStringSubmatch(answer, maxMatches)
|
||||
|
||||
return matches != nil
|
||||
}), key)
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// QueryString prompts the user and returns the answer if it passes the validator.
|
||||
func (ud *UserDialog) QueryString(prompt string, validator func(string) error, key string) string {
|
||||
if validator == nil {
|
||||
validator = func(string) error { return nil }
|
||||
}
|
||||
|
||||
answer, foundProp := ud.props[key]
|
||||
if foundProp {
|
||||
err := validator(answer)
|
||||
colAnswer := format.ColorizeValue(answer, false)
|
||||
colKey := format.ColorizeProp(key)
|
||||
|
||||
if err == nil {
|
||||
ud.Writelnf("Using prop value %v for %v", colAnswer, colKey)
|
||||
|
||||
return answer
|
||||
}
|
||||
|
||||
ud.Writelnf("Supplied prop value %v is not valid for %v: %v", colAnswer, colKey, err)
|
||||
}
|
||||
|
||||
for {
|
||||
ud.Write("%v ", prompt)
|
||||
color.Set(color.FgHiWhite)
|
||||
|
||||
if !ud.scanner.Scan() {
|
||||
if err := ud.scanner.Err(); err != nil {
|
||||
ud.Writelnf(err.Error())
|
||||
|
||||
continue
|
||||
}
|
||||
// Input closed, return an empty string
|
||||
return ""
|
||||
}
|
||||
|
||||
answer = ud.scanner.Text()
|
||||
|
||||
color.Unset()
|
||||
|
||||
if err := validator(answer); err != nil {
|
||||
ud.Writelnf("%v", err)
|
||||
ud.Writelnf("")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return answer
|
||||
}
|
||||
}
|
||||
|
||||
// QueryStringPattern prompts the user and returns the answer if it matches the regex pattern.
|
||||
func (ud *UserDialog) QueryStringPattern(
|
||||
prompt string,
|
||||
validator *regexp.Regexp,
|
||||
key string,
|
||||
) string {
|
||||
if validator == nil {
|
||||
panic("validator cannot be nil")
|
||||
}
|
||||
|
||||
return ud.QueryString(prompt, func(s string) error {
|
||||
if validator.MatchString(s) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return errInvalidFormat
|
||||
}, key)
|
||||
}
|
||||
|
||||
// QueryInt prompts the user and returns the answer as an integer if parseable.
|
||||
func (ud *UserDialog) QueryInt(prompt string, key string, bitSize int) int64 {
|
||||
validator := regexp.MustCompile(`^((0x|#)([0-9a-fA-F]+))|(-?[0-9]+)$`)
|
||||
|
||||
var value int64
|
||||
|
||||
ud.QueryString(prompt, func(answer string) error {
|
||||
groups := validator.FindStringSubmatch(answer)
|
||||
if len(groups) < 1 {
|
||||
return errNotANumber
|
||||
}
|
||||
|
||||
number := groups[0]
|
||||
|
||||
base := 0
|
||||
if groups[2] == "#" {
|
||||
// Explicitly treat #ffa080 as hexadecimal
|
||||
base = 16
|
||||
number = groups[3]
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
value, err = strconv.ParseInt(number, base, bitSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing integer from %q: %w", answer, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}, key)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryBool prompts the user and returns the answer as a boolean if parseable.
|
||||
func (ud *UserDialog) QueryBool(prompt string, key string) bool {
|
||||
var value bool
|
||||
|
||||
ud.QueryString(prompt, func(answer string) error {
|
||||
parsed, ok := format.ParseBool(answer, false)
|
||||
if ok {
|
||||
value = parsed
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w: use %v or %v",
|
||||
errInvalidBoolFormat,
|
||||
format.ColorizeTrue("yes"),
|
||||
format.ColorizeFalse("no"),
|
||||
)
|
||||
}, key)
|
||||
|
||||
return value
|
||||
}
|
184
pkg/util/generator/generator_test.go
Normal file
184
pkg/util/generator/generator_test.go
Normal file
|
@ -0,0 +1,184 @@
|
|||
package generator_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/onsi/ginkgo/v2"
|
||||
"github.com/onsi/gomega"
|
||||
"github.com/onsi/gomega/gbytes"
|
||||
|
||||
"github.com/nicholas-fedor/shoutrrr/pkg/util/generator"
|
||||
)
|
||||
|
||||
func TestGenerator(t *testing.T) {
|
||||
gomega.RegisterFailHandler(ginkgo.Fail)
|
||||
ginkgo.RunSpecs(t, "Generator Suite")
|
||||
}
|
||||
|
||||
var (
|
||||
client *generator.UserDialog
|
||||
userOut *gbytes.Buffer
|
||||
userIn *gbytes.Buffer
|
||||
)
|
||||
|
||||
func mockTyped(a ...any) {
|
||||
_, _ = fmt.Fprint(userOut, a...)
|
||||
_, _ = fmt.Fprint(userOut, "\n")
|
||||
}
|
||||
|
||||
func dumpBuffers() {
|
||||
for _, line := range strings.Split(string(userIn.Contents()), "\n") {
|
||||
_, _ = fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n")
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(userOut.Contents()), "\n") {
|
||||
_, _ = fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n")
|
||||
}
|
||||
}
|
||||
|
||||
var _ = ginkgo.Describe("GeneratorCommon", func() {
|
||||
ginkgo.BeforeEach(func() {
|
||||
userOut = gbytes.NewBuffer()
|
||||
userIn = gbytes.NewBuffer()
|
||||
userInMono := colorable.NewNonColorable(userIn)
|
||||
client = generator.NewUserDialog(
|
||||
userOut,
|
||||
userInMono,
|
||||
map[string]string{"propKey": "propVal"},
|
||||
)
|
||||
})
|
||||
|
||||
ginkgo.It("reprompt upon invalid answers", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", generator.Required, "")
|
||||
}()
|
||||
|
||||
mockTyped("")
|
||||
mockTyped("Normal Human Name")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`name: `))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`field is required`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`name: `))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("Normal Human Name")))
|
||||
})
|
||||
|
||||
ginkgo.It("should accept any input when validator is nil", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", nil, "")
|
||||
}()
|
||||
mockTyped("")
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.BeEmpty()))
|
||||
})
|
||||
|
||||
ginkgo.It("should use predefined prop value if key is present", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
go func() {
|
||||
answer <- client.QueryString("name:", generator.Required, "propKey")
|
||||
}()
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("propVal")))
|
||||
})
|
||||
|
||||
ginkgo.Describe("Query", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan []string)
|
||||
query := "pick foo or bar:"
|
||||
go func() {
|
||||
answer <- client.Query(query, regexp.MustCompile("(foo|bar)"), "")
|
||||
}()
|
||||
|
||||
mockTyped("")
|
||||
mockTyped("foo")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.ContainElement("foo")))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryAll", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan [][]string)
|
||||
query := "pick foo or bar:"
|
||||
go func() {
|
||||
answer <- client.QueryAll(query, regexp.MustCompile(`foo(ba[rz])`), "", -1)
|
||||
}()
|
||||
|
||||
mockTyped("foobar foobaz")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
var matches [][]string
|
||||
gomega.Eventually(answer).Should(gomega.Receive(&matches))
|
||||
gomega.Expect(matches).To(gomega.ContainElement([]string{"foobar", "bar"}))
|
||||
gomega.Expect(matches).To(gomega.ContainElement([]string{"foobaz", "baz"}))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryStringPattern", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan string)
|
||||
query := "type of bar:"
|
||||
go func() {
|
||||
answer <- client.QueryStringPattern(query, regexp.MustCompile(".*bar"), "")
|
||||
}()
|
||||
|
||||
mockTyped("foo")
|
||||
mockTyped("foobar")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("foobar")))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryInt", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan int64)
|
||||
query := "number:"
|
||||
go func() {
|
||||
answer <- client.QueryInt(query, "", 64)
|
||||
}()
|
||||
|
||||
mockTyped("x")
|
||||
mockTyped("0x20")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`not a number`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal(int64(32))))
|
||||
})
|
||||
})
|
||||
|
||||
ginkgo.Describe("QueryBool", func() {
|
||||
ginkgo.It("should prompt until a valid answer is provided", func() {
|
||||
defer dumpBuffers()
|
||||
answer := make(chan bool)
|
||||
query := "cool?"
|
||||
go func() {
|
||||
answer <- client.QueryBool(query, "")
|
||||
}()
|
||||
|
||||
mockTyped("maybe")
|
||||
mockTyped("y")
|
||||
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(`answer must be yes or no`))
|
||||
gomega.Eventually(userIn).Should(gbytes.Say(query))
|
||||
gomega.Eventually(answer).Should(gomega.Receive(gomega.BeTrue()))
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Add table
Add a link
Reference in a new issue