Build a task manager CLI in Go

Build a task manager CLI in Go: add, list, complete and remove tasks, persisting them to a JSON file with a Task struct and encoding/json.

Estimated time
~6 h
Skills
CLI, structs, files, JSON
On this page

In this project you build a command-line task manager that remembers your tasks between runs. Each task is a struct; the whole list is stored in a slice and persisted to a tasks.json file with the standard encoding/json package. You will support four commands β€” add, list, done and remove β€” invoked like go run . add "write blog post".

Skills you practise

  • Modelling records with a struct and JSON field tags.
  • Reading and writing files with the os package.
  • Serialising and parsing data with encoding/json.
  • Handling the β€œfile does not exist yet” case gracefully with errors.Is.
  • Parsing command-line arguments from os.Args.

Prerequisites

This is an intermediate project. Make sure you are comfortable with:

If you are new to CLIs in Go, start with the simpler command-line quiz project first.

Final file tree

task-manager/
β”œβ”€β”€ go.mod
β”œβ”€β”€ main.go
└── tasks.json   (created on first save)

Steps

1. Initialise the module and model a task

Run go mod init taskmanager, then describe a task. The JSON tags control how each field is named on disk.

package main

// Task is a single to-do item persisted to disk.
type Task struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
}

const storeFile = "tasks.json"

2. Load and save the list as JSON

Loading must tolerate a missing file on the very first run: errors.Is lets us detect that specific case and return an empty list instead of an error.

import (
	"encoding/json"
	"errors"
	"os"
)

// load reads the task list from path. A missing file yields an empty list.
func load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, err
	}
	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

// save writes the task list to path as indented JSON.
func save(path string, tasks []Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0o644)
}

3. Add, complete and remove tasks

These helpers operate purely on slices, which keeps them easy to reason about and to test. New IDs are one more than the highest existing ID.

import "fmt"

// nextID returns the smallest free ID (highest existing + 1).
func nextID(tasks []Task) int {
	highest := 0
	for _, t := range tasks {
		if t.ID > highest {
			highest = t.ID
		}
	}
	return highest + 1
}

// add appends a new task with the given text.
func add(tasks []Task, text string) []Task {
	return append(tasks, Task{ID: nextID(tasks), Text: text})
}

// setDone marks the task with the given ID as complete.
func setDone(tasks []Task, id int) ([]Task, error) {
	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Done = true
			return tasks, nil
		}
	}
	return tasks, fmt.Errorf("task %d not found", id)
}

// remove deletes the task with the given ID.
func remove(tasks []Task, id int) ([]Task, error) {
	for i := range tasks {
		if tasks[i].ID == id {
			return append(tasks[:i], tasks[i+1:]...), nil
		}
	}
	return tasks, fmt.Errorf("task %d not found", id)
}

4. Wire up the command line and assemble the program

main reads the command from os.Args, loads the list, applies the change and saves it back. This is the complete, runnable program.

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"strconv"
)

// Task is a single to-do item persisted to disk.
type Task struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
}

const storeFile = "tasks.json"

// load reads the task list from path. A missing file yields an empty list.
func load(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if errors.Is(err, os.ErrNotExist) {
		return []Task{}, nil
	}
	if err != nil {
		return nil, err
	}
	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

// save writes the task list to path as indented JSON.
func save(path string, tasks []Task) error {
	data, err := json.MarshalIndent(tasks, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0o644)
}

// nextID returns the smallest free ID (highest existing + 1).
func nextID(tasks []Task) int {
	highest := 0
	for _, t := range tasks {
		if t.ID > highest {
			highest = t.ID
		}
	}
	return highest + 1
}

// add appends a new task with the given text.
func add(tasks []Task, text string) []Task {
	return append(tasks, Task{ID: nextID(tasks), Text: text})
}

// setDone marks the task with the given ID as complete.
func setDone(tasks []Task, id int) ([]Task, error) {
	for i := range tasks {
		if tasks[i].ID == id {
			tasks[i].Done = true
			return tasks, nil
		}
	}
	return tasks, fmt.Errorf("task %d not found", id)
}

// remove deletes the task with the given ID.
func remove(tasks []Task, id int) ([]Task, error) {
	for i := range tasks {
		if tasks[i].ID == id {
			return append(tasks[:i], tasks[i+1:]...), nil
		}
	}
	return tasks, fmt.Errorf("task %d not found", id)
}

func main() {
	if len(os.Args) < 2 {
		fmt.Println("usage: tasks add|list|done|remove [args]")
		os.Exit(1)
	}

	tasks, err := load(storeFile)
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	switch os.Args[1] {
	case "add":
		if len(os.Args) < 3 {
			fmt.Fprintln(os.Stderr, "usage: tasks add <text>")
			os.Exit(1)
		}
		tasks = add(tasks, os.Args[2])
	case "list":
		for _, t := range tasks {
			status := " "
			if t.Done {
				status = "x"
			}
			fmt.Printf("[%s] %d %s\n", status, t.ID, t.Text)
		}
	case "done", "remove":
		if len(os.Args) < 3 {
			fmt.Fprintln(os.Stderr, "usage: tasks", os.Args[1], "<id>")
			os.Exit(1)
		}
		id, err := strconv.Atoi(os.Args[2])
		if err != nil {
			fmt.Fprintln(os.Stderr, "invalid id:", os.Args[2])
			os.Exit(1)
		}
		if os.Args[1] == "done" {
			tasks, err = setDone(tasks, id)
		} else {
			tasks, err = remove(tasks, id)
		}
		if err != nil {
			fmt.Fprintln(os.Stderr, "error:", err)
			os.Exit(1)
		}
	default:
		fmt.Fprintln(os.Stderr, "unknown command:", os.Args[1])
		os.Exit(1)
	}

	if err := save(storeFile, tasks); err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}
}

Try it out:

go run . add "write blog post"
go run . add "review PR"
go run . done 1
go run . list

Acceptance criteria

  • add appends a task and gives it a unique, incrementing ID.
  • list shows each task with a [ ] or [x] done marker.
  • done <id> marks a task complete; remove <id> deletes it.
  • The state survives between runs via tasks.json.
  • The first run works even though tasks.json does not exist yet.
  • An unknown command or a bad ID prints an error and exits non-zero.

Extensions

  • Add a clear command that removes all completed tasks.
  • Store a creation timestamp on each task with time.Time.
  • Support due dates and sort the list by them.
  • Replace positional arguments with the flag package for richer options.

Tests

The slice helpers are pure functions, so they are easy to test in isolation. Add a main_test.go file and run go test ./....

package main

import "testing"

func TestNextID(t *testing.T) {
	cases := []struct {
		name  string
		tasks []Task
		want  int
	}{
		{"empty list", nil, 1},
		{"sequential ids", []Task{{ID: 1}, {ID: 2}}, 3},
		{"ids with gaps", []Task{{ID: 1}, {ID: 5}}, 6},
	}

	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			if got := nextID(c.tasks); got != c.want {
				t.Errorf("nextID() = %d, want %d", got, c.want)
			}
		})
	}
}

Conclusion

You have built a persistent, testable command-line tool and practised structs, slices, file I/O and JSON serialisation β€” the everyday building blocks of Go programs. From here, try the extensions above, or revisit the command-line quiz project and make it load its questions from a JSON file using what you learned here.

Search

Search runs entirely in your browser.