Building a CLI Quiz Application in Go

Introduction

In this project, we'll be building a CLI tool—a quiz application that reads quiz questions from a customizable CSV file and outputs the number of correct and incorrect answers from the user within a customizable time limit. My strategy for this challenge is to utilize available resources on the internet (excluding the solution) to build the application efficiently.

Tools and Packages

Go provides a flag package for handling command-line arguments, and we'll also be using the encoding/csv and os packages to read CSV files. After a quick search, it's clear that Go's flag package is well-suited for our needs.

Reading CSV Records

I've created a function to read records from a CSV file and return a [][]string. Here's the code:

// Reads the CSV file and returns its content as a slice of slices of strings
func ReadCSV(path string) ([][]string, error) {
    file, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer file.Close()

    reader := csv.NewReader(file)
    records, err := reader.ReadAll()
    if err != nil {
        return nil, err
    }

    return records, nil
}

Command-line Arguments with flag Package

To read command-line arguments, I'm using the flag package. We specify how we want our flags to be present and then instruct the package to parse the arguments, updating the referenced variables in our program.

Implementing the Quiz Logic

With a list of records from the CSV file and user-specified flags, I iterate through each record to present questions to the user.

Here is the `main()` function:

func main() {

    var path string
    var duration int

    flag.StringVar(&path, "filepath", "", "Specify a CSV file containing records")
    flag.IntVar(&duration, "duration", 30, "Specify a quiz duration")
    flag.Parse()

    records, err := ReadCSV(path)
    if err != nil {
        log.Fatal("Error while reading the file", err)
    }

    fmt.Println("Successfully! Parsed the Questions file")
    for i := 3; i > 0; i -= 1 {
        fmt.Printf("Starting the Quiz in %v...\n", i)
        time.Sleep(time.Second)
    }
    fmt.Print("\n\n\n")

    var correctCount int
    var incorrectCount int

    for i, record := range records {
        fmt.Printf("Problem #%v: %v - ", i+1, record[0])
        var userInput string
        fmt.Scan(&userInput)

        if userInput == record[1] {
            correctCount += 1
        } else {
            incorrectCount += 1
        }
    }

    fmt.Println("You have successfully completed the quiz game.")
    fmt.Printf("Your Score: %v out of %v", correctCount, correctCount+incorrectCount)

}

Now we have a basic quiz application ready but, we still don't have the functionality of making the time limit of quiz customizable.

I am using channels to block the main from exiting until the user alloted time for the quiz is elapsed.

doneC := make(chan struct{})

    // kind of a timer which sends of a signal to doneC channel to
    // which another go routinte is listening for stopping the quiz
    go func() {
        time.Sleep(time.Duration(duration * int(time.Second)))
        doneC <- struct{}{}
    }()

The `main()` function is modified like this:

func main() {

    var path string
    var duration int

    flag.StringVar(&path, "file", "problems.csv", "Specify a CSV file containing records")
    flag.IntVar(&duration, "limit", 30, "Specify a quiz duration")

    flag.Parse()

    records, err := ReadCSV(path)
    shuffle(records)

    if err != nil {
        log.Fatal("Error while reading the file", err)
    }

    fmt.Println("Successfully! Parsed the Questions file")
    for i := 3; i > 0; i -= 1 {
        fmt.Printf("Starting the Quiz in %v...\n", i)
        time.Sleep(time.Second)
    }
    fmt.Print("\n\n\n")

    var correctCount int
    var incorrectCount int
    doneC := make(chan struct{})

    // kind of a timer which sends of a signal to doneC channel to
    // which another go routinte is listening for stopping the quiz
    go func() {
        time.Sleep(time.Duration(duration * int(time.Second)))
        doneC <- struct{}{}
    }()

    for i, record := range records {
        select {
        case <-doneC:
            goto end
        default:
            fmt.Printf("Problem #%v: %v - ", i+1, record[0])
            var userInput string
            fmt.Scan(&userInput)

            if userInput == record[1] {
                correctCount += 1
            } else {
                incorrectCount += 1
            }
        }
    }
end:
    fmt.Println("You have successfully completed the quiz game.")
    fmt.Printf("Your Score: %v out of %v", correctCount, correctCount+incorrectCount)

}

You can notice I have added a shuffle functionality as well. I have implemented my own function for shuffling the quiz questions each time it is run.

// This is an acceptable implementation
func shuffle(records [][]string) {
    // Using the Durstenfeld Shuffle (Modern Fisher Yates Shuffle)
    lastUnshuffledIdx := len(records) - 1
    for i := 0; i < len(records); i++ {
        j := rand.Intn(len(records) - i)
        records[j], records[lastUnshuffledIdx] = records[lastUnshuffledIdx], records[j]
        lastUnshuffledIdx--
    }
}

You can read more about this here.

Finally, this is what I am playing with:

You can access my code on GitHub here.

Conclusion

This marks the end of our journey to build a CLI quiz application in Go. Stay tuned for more updates and keep the puns coming!