Skip to content

How to Build A CLI Todo App in Go

Published:

Introduction

In this tutorial, I’m going to show you how to create a CLI To-Do app using Go (Golang).

We’ll build this application from the ground up, and by the end, you’ll have a working command-line tool where you can manage your todos.

You’ll gain practical experience in working with flag arguments to parse command-line inputs and handling JSON data for storing and reading your todos directly from a JSON file.

Go CLI Todo App

Setup

Main File Setup

Create a main.go file with package main & a main function. For now we can just print out something we will come back to it later.

package main

import "fmt"

func main() {
    fmt.Println("Inside main in todo app")
}

Todo Structure

Create a todo.go here we will define the todo structure & functionality.

type Todo struct {
    Title       string
    Completed   bool
    CreatedAt   time.Time
    CompletedAt *time.Time
}

These are all the fields we will need for a todo. We also need a slice that will hold all our todos.

type Todos []Todo

Todo Functionality

We are now gonna implement all the methods lets start by creating the add method.

func (todos *Todos) add(title string) {
    todo := Todo{
        Title:       title,
        Completed:   false,
        CompletedAt: nil,
        CreatedAt:   time.Now(),
    }

    *todos = append(*todos, todo)
}

In this method, we create a new to-do item by setting it’s title and marking it as not completed, with the creation date set to the current time. We then add this new todo directly to the existing list of to-dos using a pointer (*todos). This pointer allows us to access and modify the original list stored in memory.

After that we gonna add a helper method that checks if the provided index for operations like remove, edit, or toggle is valid

func (todos *Todos) validateIndex(index int) error {
    if index < 0 || index >= len(*todos) {
        err := errors.New("invalid index")
        fmt.Println(err.Error())
        return err
    }
    return nil
}

If the index is out of bounds, it prints an ‘invalid index’ error and returns it. Otherwise, it returns nil, indicating the index is valid.

The next method we will implement is the delete method

func (todos *Todos) delete(index int) error {
    t := *todos
    if err := t.validateIndex(index); err != nil {
        return err
    }

    *todos = append(t[:index], t[index+1:]...)

    return nil
}

In the delete method, we first verify the provided index using our helper method to ensure it is within the valid range. If the index is valid, we remove the to-do item by splitting the list at the specified index. We then join the two sections before and after the index, which removes the item from the list.

Running the App

To see our to-do application in action, let’s open the main.go file and run a quick test.

package main

import "fmt"

func main() {
    todos := Todos{}
    todos.add("Buy Milk")
    todos.add("Buy Bread")
    fmt.Printf("%+v\n\n", todos)
    todos.delete(0)
    fmt.Printf("%+v", todos)
}

This allows us to add items to our to-do list and remove them as needed. Next, we’ll enhance the functionality by adding the ability to toggle a to-do’s completion status.

func (todos *Todos) toggle(index int) error {
    t := (*todos)
    if err := t.validateIndex(index); err != nil {
        return err
    }

    isCompleted := t[index].Completed

    if !isCompleted {
        completionTime := time.Now()
        t[index].CompletedAt = &completionTime
    }

    t[index].Completed = !isCompleted

    return nil
}

In the toggle method, we start by dereferencing the todos pointer and validating the provided index to ensure it is within the correct range. If the item at this index is not already completed, we mark the current time as its completion time. We then flip the Completed status. If it was false (not completed), it becomes true (completed), and vice versa.

Next we have the edit metod.

func (todos *Todos) edit(index int, title string) error {
t := *todos

if err := t.validateIndex(index); err != nil {
	return err
}

t[index].Title = title

return nil
}

Same as the other methods we validate the index if it’s valed get the todo from the todos slice and update the tile.

The final method we need for our to-do list is a way to display it neatly. We’ll use a third-party package to create a visually appealing table. First, install the package by running the following command in the terminal.

Go get github.com/aquasecurity/table

Next, we’ll create a method called print.

func (todos *Todos) print() {
    table := table.New(os.Stdout)
    table.SetRowLines(false)
    table.SetHeaders("#", "Title", "Completed", "Created At", "Completed At")

    for index, t := range *todos {
        completed := "❌"
        completedAt := ""
       
        if t.Completed {
            completed = "✅"
            if t.CompletedAt != nil {
                completedAt = t.CompletedAt.Format(time.RFC1123)

            }

        }

        table.AddRow(strconv.Itoa(index), t.Title, completed, t.CreatedAt.Format(time.RFC1123), completedAt)

    }

    table.Render()
}

In this method, we set up a new table directed to output in the console. We configure the table by turning off row lines and setting column headers.

Then, we loop through each to-do item. If an item is completed, we mark it with a checkmark and include the completion date. Otherwise, we use a cross mark and leave the completion date blank. Each to-do item is added as a row in the table with its details.

Finally, we render the table to the console.

Here is the full code for the todo.go

package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
	"time"

	"github.com/aquasecurity/table"
)

type Todo struct {
	Title       string
	Completed   bool
	CreatedAt   time.Time
	CompletedAt *time.Time
}

type Todos []Todo

func (todos *Todos) add(title string) {
	todo := Todo{
		Title:       title,
		Completed:   false,
		CompletedAt: nil,
		CreatedAt:   time.Now(),
	}

	*todos = append(*todos, todo)
}

func (todos *Todos) validateIndex(index int) error {
	if index < 0 || index >= len(*todos) {
		err := errors.New("invalid index")
		fmt.Println(err)
		return err
	}

	return nil
}

func (todos *Todos) delete(index int) error {
	t := *todos

	if err := t.validateIndex(index); err != nil {
		return err
	}

	*todos = append(t[:index], t[index+1:]...)

	return nil
}

func (todos *Todos) toggle(index int) error {
	t := *todos

	if err := t.validateIndex(index); err != nil {
		return err
	}

	isCompleted := t[index].Completed

	if !isCompleted {
		completionTime := time.Now()
		t[index].CompletedAt = &completionTime
	}

	t[index].Completed = !isCompleted

	return nil
}

func (todos *Todos) edit(index int, title string) error {
	t := *todos

	if err := t.validateIndex(index); err != nil {
		return err
	}

	t[index].Title = title

	return nil
}

func (todos *Todos) print() {
	table := table.New(os.Stdout)
	table.SetRowLines(false)
	table.SetHeaders("#", "Title", "Completed", "Created At", "Completed At")
	for index, t := range *todos {
		completed := "❌"
		completedAt := ""

		if t.Completed {
			completed = "✅"
			if t.CompletedAt != nil {
				completedAt = t.CompletedAt.Format(time.RFC1123)
			}
		}

		table.AddRow(strconv.Itoa(index), t.Title, completed, t.CreatedAt.Format(time.RFC1123), completedAt)
	}

	table.Render()
}

Lets’ try it out.

package main

func main() {
    todos := Todos{}
    todos.add("Buy Milk")
    todos.add("Buy Bread")
    todos.toggle(0)
    todos.print()
}

We start by adding a couple of tasks: ‘Buy Milk’ and ‘Buy Bread’. Next, we toggle the completion status of the first item to mark it as complete. Then, we use the print method to display our tasks in a structured table.

It would be nice to have functionality to save our to-do list to a file and read it back from there, keeping our data persistent between sessions. Let’s implement that next.

Create a file called storage.go

import (
    "encoding/json"
    "os"
)

type Storage[T any] struct {
    FileName string
}

func NewStorage[T any](fileName string) *Storage[T] {
    return &Storage[T]{FileName: fileName}
}

func (s *Storage[T]) Save(data T) error {
    fileData, err := json.MarshalIndent(data, "", "")
   
    if err != nil {
        return err
    }
   
    return os.WriteFile(s.FileName, fileData, 0644)
}

func (s *Storage[T]) Load(data *T) error {
    fileData, err := os.ReadFile(s.FileName)
   
    if err != nil {
        return err
    }

    return json.Unmarshal(fileData, data)
}

This code defines a Storage structure to manage file operations generically, which means it can handle any type, such as our to-do list or other data types in the future.

In the NewStorage We initiate a Storage instance with a specified file name.

The save function takes any data of type T and saves it into a file in JSON format. It neatly formats the JSON for better readability and sets appropriate file permissions.

The load function retrieves data from the specified file and converts the JSON back into our data structure of type T.

Lets try it out

package main

func main() {
    todos := Todos{}
    storage := NewStorage[Todos]("todos.json")
    storage.Load(&todos)
    todos.add("Buy Milk")
    todos.add("Buy Bread")
    todos.toggle(0)
    todos.print()
    storage.Save(todos)
}

We start by creating a new storage with the Todos type & the file name todos.json. This file doesn’t exist, so the Load function won’t find any data to load. We then add some todos to our list and save them. This process creates the todos.json file with our new data. If we run the program again, it will load the todos from this file back into the program.

The last part that is missing is to be able to run the program with different commands. Create a new file called command.go

package main

import (
    "flag"
    "fmt"
    "os"
    "strconv"
    "strings"
)

type CmdFlags struct {
    Add    string
    Del    int
    Edit   string
    Toggle int
    List   bool
}

func NewCmdFlags() *CmdFlags {
    cf := CmdFlags{}
   
    flag.StringVar(&cf.Add, "add", "", "Add a new todo specify title")
    flag.StringVar(&cf.Edit, "edit", "", "Edit a todo by index & specify a new title. id:new_title")
    flag.IntVar(&cf.Del, "del", -1, "Specify todo by index to delete")
    flag.IntVar(&cf.Toggle, "toggle", -1, "Specify todo by index to toggle complete true/false")
    flag.BoolVar(&cf.List, "list", false, "List all todos")
   
    flag.Parse()
   
    return &cf
}

This block of code sets up the structure with all available commands and initializes the command-line flags. Each flag is defined with a specific type, a pointer to the data, a default value, and a description. We then parse these flags to populate the CmdFlags structure.

Next, we implement an Execute function that evaluates the values in CmdFlags and invokes the corresponding method from the Todos structure


func (cf *CmdFlags) Execute(todos *Todos) {
    switch {
    case cf.List:
        todos.print()
    case cf.Add != "":
        todos.add(cf.Add)
    case cf.Edit != "":
        parts := strings.SplitN(cf.Edit, ":", 2)
        if len(parts) != 2 {
            fmt.Println("Error: Invalid format for edit. Please use index:new_title")
            os.Exit(1)
        }
        index, err := strconv.Atoi(parts[0])
        if err != nil {
            fmt.Println("Error: Invalid index for edit.")
            os.Exit(1)

        }
        todos.edit(index, parts[1])
    case cf.Toggle != -1:
        todos.toggle(cf.Toggle)

    case cf.Del != -1:
        todos.delete(cf.Del)

    default:
        fmt.Println("Invalid command")
    }
}

This function checks each flag in the CmdFlags to see which action should be performed,

Here is the full code for the command.go

package main

import (
	"flag"
	"fmt"
	"os"
	"strconv"
	"strings"
)

type CmdFlags struct {
	Add    string
	Del    int
	Edit   string
	Toggle int
	List   bool
}

func NewCmdFlags() *CmdFlags {
	cf := CmdFlags{}

	flag.StringVar(&cf.Add, "add", "", "Add a new todo specify title")
	flag.StringVar(&cf.Edit, "edit", "", "Edit a todo by index & specify a new title. id:new_title")
	flag.IntVar(&cf.Del, "del", -1, "Specify a todo by index to delete")
	flag.IntVar(&cf.Toggle, "toggle", -1, "Specify a todo by index to toggle")
	flag.BoolVar(&cf.List, "list", false, "List all todos")

	flag.Parse()

	return &cf
}

func (cf *CmdFlags) Execute(todos *Todos) {
	switch {
	case cf.List:
		todos.print()
	case cf.Add != "":
		todos.add(cf.Add)
	case cf.Edit != "":
		parts := strings.SplitN(cf.Edit, ":", 2)
		if len(parts) != 2 {
			fmt.Println("Error, invalid format for edit. Please use id:new_title")
			os.Exit(1)
		}

		index, err := strconv.Atoi(parts[0])

		if err != nil {
			fmt.Println("Error: invalid index for edit")
			os.Exit(1)
		}

		todos.edit(index, parts[1])

	case cf.Toggle != -1:
		todos.toggle(cf.Toggle)

	case cf.Del != -1:
		todos.delete(cf.Del)

	default:
		fmt.Println("Invalid command")
	}
}

Finally, we update the main.go file to integrate these changes

package main

func main() {
todos := Todos{}

storage := NewStorage[Todos]("todos.json")
storage.Load(&todos)

cmdFlags := NewCmdFlags()
cmdFlags.Execute(&todos)

storage.Save(todos)
}

In the main function, we initialize the Todos and Storage, load any existing todos from a file, process commands based on user input, and then save any changes back to the file.

Full source code can be found here Github

Conclusion

We’ve reached the end of our tutorial on how to build a CLI to-do application using Go. Feel free to expand on what we’ve covered by adding new features or enhancing existing ones to make the app even more robust. Thank you for joining me today. See you in the next video!