go |cli

Build an awesome CLI using GO

dipankarmedhi

Dipankar Medhi

Posted on April 19, 2022

Build an awesome CLI using GO

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

Enter fullscreen mode Exit fullscreen mode

💡 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"
)

Enter fullscreen mode Exit fullscreen mode
  • 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

Enter fullscreen mode Exit fullscreen mode

💡 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)
}

Enter fullscreen mode Exit fullscreen mode

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
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

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"
)

Enter fullscreen mode Exit fullscreen mode

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)
    }
}

Enter fullscreen mode Exit fullscreen mode

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.")
    }
}

Enter fullscreen mode Exit fullscreen mode

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)
    }
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"
)

Enter fullscreen mode Exit fullscreen mode

Create a main() function.

func main() {

}

Enter fullscreen mode Exit fullscreen mode

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()

Enter fullscreen mode Exit fullscreen mode

💡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)
    }

Enter fullscreen mode Exit fullscreen mode

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)
    }

Enter fullscreen mode Exit fullscreen mode

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"
)

Enter fullscreen mode Exit fullscreen mode

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)
}

Enter fullscreen mode Exit fullscreen mode

**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))
        }

    })
}

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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"

Enter fullscreen mode Exit fullscreen mode

Let's try marking our tasks complete.

$ go run main.go -complete 1


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

Enter fullscreen mode Exit fullscreen mode

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💛

💖 💪 🙅 🚩
dipankarmedhi
Dipankar Medhi

Posted on April 19, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related