go

Create a Boolean Yes or No prompt in Go

Reading time of 1022 words
5 minutes
Reading time of 1022 words ~ 5 minutes


Did you find this article helpful?
Please consider tipping me a coffee as a thank you.
Ko-fi Buy Me a Coffee
Did you find this article helpful? Please consider tipping me a coffee or three as a thank you.
Tip using Ko-fi or Buy Me a Coffee

In this post, I will create a simple function that will allow you to ask the user a question, receive a valid response, and return it as a Boolean type. And if needed, the user will be able to tap Ctrl+C at any time to exit the program. Also importantly, despite using the operating system input, we will create unit tests for os.stdin.

go mod init devtidbits.com/yn

Firstly create a new function in the main.go file called YesNo that takes a string as an argument and returns a boolean value.

package main

func main() {}

func YesNo(s string) bool {
    return false
}

Now let’s call the YesNo function by asking a question and printing the returned boolean value. At the moment, this will always be false.

func main() {
    b := YesNo("Is the weather good today?")
    fmt.Println(b)
}

func YesNo(s string) bool {
    fmt.Printf("%s ", s)
    return false
}

Create a new Parse function to read the rune and return the appropriate boolean or an error.

var ErrParse = errors.New("invalid keypress, it does not match a boolean")

// Parse the rune as a bool.
// Characters y or Y return true, n or N return false.
// Everything else returns an error.
func Parse(r rune) (bool, error) {
    switch unicode.ToLower(r) {
    case rune('y'):
        return true, nil
    case rune('n'):
        return false, nil
    }
    return false, ErrParse
}

Update the YesNo function to read and parse the standard input. It also now prints the expected keyboard input.

// YesNo prints s requesting a boolean yes-or-no answer from the stardard input.
func YesNo(s string) bool {
    fmt.Printf("%s [y/n] ", s)
    input, err := Read(os.Stdin)
    if err != nil {
        log.Fatalln(err)
    }
    r, _ := Parse(input)
    return r
}
$ go run .
Is the weather good today? [y/n] y
true

$ go run .
Is the weather good today? [y/n] n
false

Instead of manually inputting various keys to test the results, let’s automate it with unit tests. Create a main_test.go file with a package main_test.

 1package main_test
 2
 3import (
 4    "io"
 5    "testing"
 6
 7    main "devtidbits.com/yn"
 8)
 9
10func TestRead(t *testing.T) {
11    var stdin bytes.Buffer
12    tests := []struct {
13        name     string
14        keypress string
15        want     rune
16        wantErr  bool
17    }{
18        // TODO: Add test cases.
19    }
20    for _, tt := range tests {
21        t.Run(tt.name, func(t *testing.T) {
22            stdin.WriteString(tt.keypress)
23            got, err := main.Read(&stdin)
24            if (err != nil) != tt.wantErr {
25                t.Errorf("Read() error = %v, wantErr %v", err, tt.wantErr)
26                return
27            }
28            if got != tt.want {
29                t.Errorf("Read() = %v, want %v", got, tt.want)
30            }
31        })
32    }
33}

The devtidbits.com/yn module in the go.mod file is imported as main. The TestRead function is a boilerplate created by VSCode, and I’ve then modified it to simulate a standard input keypress.

Replace // TODO: Add test cases. with our test arguments.

}{
    {"empty", "", 0, true},
    {"newline", "\n", rune('\n'), false},
    {"space", " ", rune(' '), false},
    {"upper y", "Y", rune('Y'), false},
    {"lower y", "y", rune('y'), false},
    {"multibyte", "😃", rune('😃'), false},
    {"Yes", "Yes", rune('Y'), false},
}

The “empty” test returns an error because stdin cannot be null and always returns a value.

The “newline” and “space” tests confirm whitespace inputs work as expected. A newline control is the return value for the Enter or Return keypresses.

The “upper y” and “lower y” tests confirm case matching.

The “multibyte” test confirms characters larger than a single byte work correctly.

And the “Yes” test shows that only the first character of the standard input is ever read and returned.

Create a TestParse for the Parse function, mainly using the same TestRead cases. This test will be different though as incorrect inputs return errors.

func TestParse(t *testing.T) {
    tests := []struct {
        name    string
        r       rune
        want    bool
        wantErr bool
    }{
        // incorrect values
        {"empty", 0, false, true},
        {"newline", rune('\n'), false, true},
        {"space", rune(' '), false, true},
        {"multibyte", rune('😃'), false, true},
        // correct values
        {"Yes", rune('Y'), true, false},
        {"yes", rune('y'), true, false},
        {"No", rune('N'), false, false},
        {"no", rune('n'), false, false},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := main.Parse(tt.r)
            if (err != nil) != tt.wantErr {
                t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr)
                return
            }
            if got != tt.want {
                t.Errorf("Parse() = %v, want %v", got, tt.want)
            }
        })
    }
}

Don’t forget to run the tests.

$ go test .
ok      devtidbits.com/yn       0.002s

Bonus, limit the keypresses.

Currently, YesNo returns false for all keypresses except for Y or Caps Lock+Y. Assuming false for all other keys presses is generally bad for usability and accidental taps. Instead, YesNo should only work with the N and Y keys. Otherwise, it should remind the user of the question.

 1// YesNo prints s requesting a boolean yes-or-no answer from the stardard input.
 2func YesNo(s string) bool {
 3    for {
 4        fmt.Printf("%s [y/n] ", s)
 5        input, err := Read(os.Stdin)
 6        if err != nil {
 7            log.Fatalln(err)
 8        }
 9        if r, err := Parse(input); err == nil {
10            return r
11        }
12    }
13}
$ go run .
Is the weather good today? [y/n]
Is the weather good today? [y/n] x
Is the weather good today? [y/n] ?
Is the weather good today? [y/n] y
true

Bonus, intercept Ctrl+C with a graceful exit

A user can tap Ctrl+C to quit during the Parse loop, which causes an ugly signal interrupt exit.

$ go run .
Is the weather good today? [y/n] ^Csignal: interrupt

Create a new CtrlC function that will intercept the signal and gracefully exit with an emoji handwave. We must also call this function in YesNo before its input loop.

// CtrlC intercepts any Ctrl+C keyboard input and exits to the shell.
func CtrlC() {
    c := make(chan os.Signal, 1)
    signal.Notify(c, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-c
        fmt.Fprintf(os.Stdout, " 👋\n")
        os.Exit(0)
    }()
}
1func YesNo(s string) bool {
2    CtrlC()
3    for {
Is the weather good today? [y/n] ^C 👋

That’s it, nice work, and thank you for reading. The complete source code is on my GitHub repo.

Preview of the running application

Written by Ben Garrett

Did you find this article helpful?
Please consider tipping me a coffee as a thank you.
Ko-fi Buy Me a Coffee
Did you find this article helpful? Please consider tipping me a coffee or three as a thank you.
Tip using Ko-fi or Buy Me a Coffee