Build an awesome CLI using GO
Tags: go, cli
Published: 2022-04-19 03:12:26

---
title: Build an awesome CLI using GO
published: true
date: 2022-04-14 10:24:28 UTC
tags: golang, CLI, Go
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m3cc13id3fvrknh8szez.png
---

## 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](https://go.dev/solutions/clis)

## 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](https://cdn.hashnode.com/res/hashnode/image/upload/v1648836061662/d_Q0eO5P3.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💛