Build a todo CLI using GO

Build a todo CLI using GO

In this article, we build a simple todo-CLI using the Go programming language.

CLI in go

Go is great for building CLI applications. It provides two very powerful tools cobra-cli and viper. But in this example, we are going to use the flag package and other built-in tools.

For more information on CLI using go, visit go.dev

Creating project structure and go module

  • First we create a directory, I have named it go-todo-cli. You can give your own name.
  • Inside that create two more directories, cmd/todo, where we will have the command-line interface code.
  • Add main.go and main_test.go files inside cmd/todo directory.
  • Add todo.go and todo_test.go files inside the parent directory.

A graphical representation for a better understanding of the project folder structure.

image.png

Then initialize the Go module for the project by using go mod init <your module name>.

go mod init github.com/dipankar-medhi/TodoCli

💡 Keeping the module name the same as the folder name can make things easy.

Coding the todo functions

  • Start by declaring the package name inside the todo.go file.
  • Import the packages.
package todo

import (
    "encoding/json"
    "errors"
    "fmt"
    "io/ioutil"
    "os"
    "time"
)
  • Then we create two data structures to be used in our package. The first one is a struct item and the second one is a list type []item.
  • The item struct will have some fields, like the Task as string, Done as bool to mark if the task is complete or not, CreatedAt as time.Time that shows the time when this task is created. And lastly, we have CompletedAt of time.Time that shows when this task is completed.
type item struct {
    Task        string
    Done        bool
    CreatedAt   time.Time
    CompletedAt time.Time
}

type List []item

💡 The struct name is lowercase cause we do not plan to export it.

Functions of our todo CLI application:

  • Add new tasks
  • Mark tasks as complete
  • Delete tasks from the list of tasks
  • Save the list of tasks as JSON
  • Get the tasks from the JSON file

So, let's start by defining the add function

Add function

This function will add new tasks to the list []item.

func (l *List) Add(task string) {
    t := item{
        Task:        task,
        Done:        false,
        CreatedAt:   time.Now(),
        CompletedAt: time.Time{},
    }

    *l = append(*l, t)
}

Complete function

This function marks an item/task as complete by setting the done field inside the item struct as true and completed at the current time.

func (l *List) Complete(i int) error {
    ls := *l
    if i <= 0 || i > len(ls) {
        return fmt.Errorf("item %d does not exist", i)
    }
    ls[i-1].Done = true
    ls[i-1].CompletedAt = time.Now()

    return nil
}

Save function

This function saves the list of tasks in JSON format.

func (l *List) Save(fileName string) error {
    json, err := json.Marshal(l)
    if err != nil {
        return err
    }
    return ioutil.WriteFile(fileName, json, 0644)
}

Get function

This function will get the saved tasks list from the directory with help of the filename and decode and parse that JSON data into a list.

It will also handle cases when the filename doesn't exist or is an empty file.

func (l *List) Get(fileName string) error {
    file, err := ioutil.ReadFile(fileName)
    if err != nil {
        // if the given file does not exist
        if errors.Is(err, os.ErrNotExist) {
            return nil
        }
        return err
    }

    if len(file) == 0 {
        return nil
    }

    return json.Unmarshal(file, l)
}

We are done with the to-do functions.

Now let's write the tests to ensure everything is working correctly as intended.

Writing tests for todo functions

  • Start by creating a todo_test.go file inside the same directory as todo.go is present.
  • Write the package name as todo_test and import the necessary packages.
package todo_test

import (
    "io/ioutil"
    "os"
    "testing"

    todo "github.com/dipankar-medhi/TodoCli"
)

Test for add function

func TestAdd(t *testing.T) {
    l := todo.List{}

    taskName := "New Task"
    l.Add(taskName)

    if l[0].Task != taskName {
        t.Errorf("Expected %q, got %q instead", taskName, l[0].Task)
    }
}

Test for complete function

func TestComplete(t *testing.T) {
    l := todo.List{}

    taskName := "New Task"
    l.Add(taskName)

    if l[0].Task != taskName {
        t.Errorf("Expected %q, got %q instead", taskName, l[0].Task)
    }

    if l[0].Done {
        t.Errorf("New task should not be completed.")
    }
    l.Complete(1)

    if !l[0].Done {
        t.Errorf("New task should be completed.")
    }
}

Test for saving and get function

func TestSaveGet(t *testing.T) {
    // two list
    l1 := todo.List{}
    l2 := todo.List{}
    taskName := "New Task"
    // saving task into l1 and loading it into l2 -- error if fails
    l1.Add(taskName)
    if l1[0].Task != taskName {
        t.Errorf("Expected %q, got %q instead.", taskName, l1[0].Task)
    }
    tf, err := ioutil.TempFile("", "")
    if err != nil {
        t.Fatalf("Error creating temp file: %s", err)
    }
    defer os.Remove(tf.Name())
    if err := l1.Save(tf.Name()); err != nil {
        t.Fatalf("Error saving list to file: %s", err)
    }
    if err := l2.Get(tf.Name()); err != nil {
        t.Fatalf("Error getting list from file: %s", err)
    }
    if l1[0].Task != l2[0].Task {
        t.Errorf("Task %q should match %q task.", l1[0].Task, l2[0].Task)
    }
}

Now let's test the application.

Save the file and use the go test tool to execute the tests.

$ go test -v
=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
=== RUN   TestComplete
--- PASS: TestComplete (0.00s)
=== RUN   TestDelete
--- PASS: TestDelete (0.00s)
=== RUN   TestSaveGet
--- PASS: TestSaveGet (0.00s)
PASS
ok      github.com/dipankar-medhi/TodoCli

It is working fine. Let's proceed to the next step.

Building the main CLI functionality

We create the main.go and main_test.go file inside cmd/todo.

Let's begin writing the code inside the main.go file.

We start by importing the packages.

package main

import (
    "flag"
    "fmt"
    "os"

    todo "github.com/dipankar-medhi/TodoCli"
)

Create a main() function.

func main() {

}

Inside the main function, write all our command-line functions and flags to be executed.

Parse the command-line flags.

    task := flag.String("task", "", "Task to be included in the todolist")
    list := flag.Bool("list", false, "List all tasks")
    complete := flag.Int("complete", 0, "Item to be completed")

    flag.Parse()

💡these are pointers, so we have to use * to use them.

    l := &todo.List{}

    //calling Get method from todo.go file
    if err := l.Get(todoFileName); err != nil {
        // in cli, stderr output is best practice
        fmt.Fprintln(os.Stderr, err)
        // another good practice is to exit the program with
        // a return code different than 0.
        os.Exit(1)
    }

Decide what to do based on the arguments provided. So we use switch for this purpose.

    switch {
    case *list:
        // list current to do items
        for _, item := range *l {
            if !item.Done {
                fmt.Println(item.Task)
            }
        }
    // to verify if complete flag is set with value more than 0 (default)
    case *complete > 0:
        if err := l.Complete(*complete); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
        // save the new list
        if err := l.Save(todoFileName); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    // verify if task flag is set with different than empty string
    case *task != "":
        l.Add(*task)
        if err := l.Save(todoFileName); err != nil {
            fmt.Fprintln(os.Stderr, err)
            os.Exit(1)
        }
    default:
        // print an error msg
        fmt.Fprintln(os.Stderr, "Invalid option")
        os.Exit(1)
    }

Writing tests for the main function

Start by importing packages and defining some variables.

package main

import (
    "fmt"
    "os"
    "os/exec"
    "path/filepath"
    "runtime"
    "testing"
)
var (
    binName  = "todo"
    fileName = ".todo.json"
)

Test for Main function

func TestMain(m *testing.M) {
    fmt.Println("Building tool...")
    if runtime.GOOS == "windows" {
        binName += ".exe"
    }
    build := exec.Command("go", "build", "-o", binName)
    if err := build.Run(); err != nil {
        fmt.Fprintf(os.Stderr, "Cannot build tool %s: %s", binName, err)
        os.Exit(1)
    }

    fmt.Println("Running tests....")
    result := m.Run()
    fmt.Println("Cleaning up...")
    os.Remove(binName)
    os.Remove(fileName)
    os.Exit(result)
}

**Tests for Todo functions

func TestTodoCLI(t *testing.T) {
    task := "test task number 1"
    dir, err := os.Getwd()
    if err != nil {
        t.Fatal(err)
    }
    cmdPath := filepath.Join(dir, binName)
    t.Run("AddNewTask", func(t *testing.T) {
        cmd := exec.Command(cmdPath, "-task", task)
        if err := cmd.Run(); err != nil {
            t.Fatal(err)
        }
    })

    t.Run("ListTasks", func(t *testing.T) {
        cmd := exec.Command(cmdPath, "-list")
        out, err := cmd.CombinedOutput()
        if err != nil {
            t.Fatal(err)
        }
        expected := task + "\n"

        if expected != string(out) {
            t.Errorf("Expected %q, got %q instead\n", expected, string(out))
        }

    })
}

We have written all our tests.

Now, let's test out the application.

Run go test -v inside cmd/todo directory.

$ go test -v
Building tool...
Running tests....
=== RUN   TestTodoCLI
=== RUN   TestTodoCLI/AddNewTask
=== RUN   TestTodoCLI/ListTasks
--- PASS: TestTodoCLI (0.51s)
    --- PASS: TestTodoCLI/AddNewTask (0.47s)
    --- PASS: TestTodoCLI/ListTasks (0.05s) 
PASS
Cleaning up...
ok      github.com/dipankar-medhi/TodoCli/cmd/todo      1.337s

We see that everything is working fine.

Now it's time to use our application.

Before getting the list of items, we should add some tasks. So we add a few items using -task flag.

$ go run main.go -task "Get Vegetables from the market"
$ go run main.go -task "Drop the package"
$ go run main.go -list
"Get Vegetables from the market"
"Drop the package"

Let's try marking our tasks complete.

$ go run main.go -complete 1
$ go run main.go -list
"Drop the package"

Conclusion

This is a simple to-do CLI that has limited functions. And by using external packages like cobra-cli, the functionality of the application can be improved to a great extent.

Reference: "Powerful Command-Line Applications in Go Build Fast and Maintainable Tools by Ricardo Gerardi"


🌎Explore, 🎓Learn, 👷‍♂️Build. Happy Coding💛