From fdf762f032cdcbf0c1aa816eb6a0ce3461616d9c Mon Sep 17 00:00:00 2001 From: Andrew A Date: Sun, 7 Jun 2026 20:19:52 +0200 Subject: [PATCH] refactor: shared confirm prompt Extracts a "prompt" helper that allows taking the default in non-interactive mode. The interactive setup command has been updated to use this same helper. Previously, it was duplicating these checks inline. --- cmd/install.go | 26 +++++---------- cmd/prompt.go | 58 ++++++++++++++++++++++++++++++++ cmd/prompt_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 cmd/prompt.go create mode 100644 cmd/prompt_test.go diff --git a/cmd/install.go b/cmd/install.go index 1afc982..6ce2e89 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,7 +1,6 @@ package cmd import ( - "bufio" "errors" "fmt" "os" @@ -35,12 +34,7 @@ func promptInitConfig() error { return err } - reader := bufio.NewReader(os.Stdin) - - fmt.Print("bight: no config file found. Create .bight.yml? [Y/n] ") - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(answer) - if answer != "" && answer != "y" && answer != "Y" { + if !confirm("bight: no config file found. Create .bight.yml?", true) { return nil } @@ -51,26 +45,24 @@ func promptInitConfig() error { defaultProject := filepath.Base(cwd) fmt.Printf(" Project name [%s]: ", defaultProject) - project, _ := reader.ReadString('\n') + project, _ := promptReader.ReadString('\n') project = strings.TrimSpace(project) if project == "" { project = defaultProject } fmt.Print(" Env file path [.env]: ") - envFile, _ := reader.ReadString('\n') + envFile, _ := promptReader.ReadString('\n') envFile = strings.TrimSpace(envFile) if envFile == "" { envFile = ".env" } var copySource string - fmt.Print(" Seed this file from another path on first worktree init? [y/N] ") - seedAnswer, _ := reader.ReadString('\n') - if v := strings.TrimSpace(seedAnswer); v == "y" || v == "Y" { + if confirm(" Seed this file from another path on first worktree init?", false) { defaultSource := envFile fmt.Printf(" Source path [%s]: ", defaultSource) - src, _ := reader.ReadString('\n') + src, _ := promptReader.ReadString('\n') copySource = strings.TrimSpace(src) if copySource == "" { copySource = defaultSource @@ -78,13 +70,11 @@ func promptInitConfig() error { } var vars []config.Var - fmt.Print(" Add env vars to track? [Y/n] ") - addVars, _ := reader.ReadString('\n') - if v := strings.TrimSpace(addVars); v == "" || v == "y" || v == "Y" { + if confirm(" Add env vars to track?", true) { fmt.Println(" (blank name to finish)") for { fmt.Print(" Var name: ") - name, _ := reader.ReadString('\n') + name, _ := promptReader.ReadString('\n') name = strings.TrimSpace(name) if name == "" { break @@ -93,7 +83,7 @@ func promptInitConfig() error { fmt.Println(" 1) template - interpolate branch/project name (default)") fmt.Println(" 2) random - fresh random value on each checkout") fmt.Print(" Choice [1]: ") - choice, _ := reader.ReadString('\n') + choice, _ := promptReader.ReadString('\n') strategy := "template" if strings.TrimSpace(choice) == "2" { strategy = "random" diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..02b3704 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +// promptReader is the source confirm() and other prompts read answers +// from. Tests swap it via setPromptReader. +var promptReader = bufio.NewReader(os.Stdin) + +// promptOut is where the question is written. Tests swap it. +var promptOut io.Writer = os.Stdout + +// isPromptTTY reports whether stdin is connected to a terminal. Tests +// swap it to force the TTY / non-TTY branch independently of promptReader. +var isPromptTTY = defaultIsPromptTTY + +func defaultIsPromptTTY() bool { + stat, err := os.Stdin.Stat() + if err != nil { + return false + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} + +// confirm prints question with a [Y/n] / [y/N] suffix and reads a y/n +// answer from stdin. If stdin is not a TTY, the question is echoed for +// visibility and def is returned without blocking. An empty line, EOF, +// or an unrecognized answer also yields def. +func confirm(question string, def bool) bool { + suffix := "[Y/n]" + if !def { + suffix = "[y/N]" + } + + if !isPromptTTY() { + fmt.Fprintf(promptOut, "%s %s\n", question, suffix) + return def + } + + fmt.Fprintf(promptOut, "%s %s ", question, suffix) + line, err := promptReader.ReadString('\n') + if err != nil && line == "" { + return def + } + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + case "n", "no": + return false + default: + return def + } +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 0000000..71eb030 --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,82 @@ +package cmd + +import ( + "bufio" + "bytes" + "strings" + "testing" +) + +// withStubPrompt swaps the package-level prompt I/O for the duration of a +// test and restores it on cleanup. tty controls whether isPromptTTY reports +// a terminal, and input is the bytes confirm/promptReader will read. +func withStubPrompt(t *testing.T, tty bool, input string) *bytes.Buffer { + t.Helper() + origIn := promptReader + origOut := promptOut + origTTY := isPromptTTY + + var out bytes.Buffer + promptReader = bufio.NewReader(strings.NewReader(input)) + promptOut = &out + isPromptTTY = func() bool { return tty } + + t.Cleanup(func() { + promptReader = origIn + promptOut = origOut + isPromptTTY = origTTY + }) + return &out +} + +func TestConfirm_NonTTYReturnsDefault(t *testing.T) { + out := withStubPrompt(t, false, "n\n") + if !confirm("Seed?", true) { + t.Errorf("non-TTY: expected default=true to be returned") + } + if !strings.Contains(out.String(), "Seed?") { + t.Errorf("non-TTY: expected question to be echoed, got %q", out.String()) + } +} + +func TestConfirm_EmptyLineReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "\n") + if !confirm("Seed?", true) { + t.Errorf("empty input with def=true should yield true") + } +} + +func TestConfirm_ParsesYesNo(t *testing.T) { + cases := []struct { + input string + def bool + want bool + }{ + {"y\n", false, true}, + {"Y\n", false, true}, + {"yes\n", false, true}, + {"YES\n", false, true}, + {"n\n", true, false}, + {"N\n", true, false}, + {"no\n", true, false}, + {" y \n", false, true}, + {"garbage\n", true, true}, // unrecognized → def + {"garbage\n", false, false}, // unrecognized → def + } + for _, c := range cases { + t.Run(strings.TrimSpace(c.input), func(t *testing.T) { + withStubPrompt(t, true, c.input) + got := confirm("?", c.def) + if got != c.want { + t.Errorf("input=%q def=%v: got %v, want %v", c.input, c.def, got, c.want) + } + }) + } +} + +func TestConfirm_EOFReturnsDefault(t *testing.T) { + withStubPrompt(t, true, "") + if !confirm("?", true) { + t.Errorf("EOF with def=true should yield true") + } +}