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.
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💛