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