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
structand JSON field tags. - Reading and writing files with the
ospackage. - 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:
- Structs and methods in Go β a task is a struct.
- Arrays and slices in Go β the task list is a slice you grow and shrink.
- Error handling in Go β file and parsing operations return errors you must check.
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
-
addappends a task and gives it a unique, incrementing ID. -
listshows 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.jsondoes not exist yet. - An unknown command or a bad ID prints an error and exits non-zero.
Extensions
- Add a
clearcommand 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
flagpackage 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.