Skip to content

10 · Project — CLI To-Do App

A small end-to-end project combining everything from Level 1: structs, slices, pointers, error handling, and packages/modules, with persistence to a JSON file on disk.

What you'll build

A command-line to-do app that:

  • Adds tasks
  • Lists all tasks (done and pending)
  • Marks a task done by its number
  • Deletes a task by its number
  • Persists everything to tasks.json between runs

Project layout

todocli/
    go.mod
    main.go
    todo/
        task.go
        store.go

Setting up the module

mkdir todocli && cd todocli
go mod init todocli
mkdir todo

todo/task.go — the data type

// todo/task.go
package todo

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

todo/store.go — JSON persistence

// todo/store.go
package todo

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

// Store manages loading and saving Tasks to a JSON file.
type Store struct {
    path string
}

// NewStore returns a Store backed by the given file path.
func NewStore(path string) *Store {
    return &Store{path: path}
}

// Load reads all tasks from disk. A missing file is not an error --
// it just means there are no tasks yet.
func (s *Store) Load() ([]Task, error) {
    data, err := os.ReadFile(s.path)
    if os.IsNotExist(err) {
        return []Task{}, nil
    }
    if err != nil {
        return nil, fmt.Errorf("reading tasks file: %w", err)
    }

    var tasks []Task
    if err := json.Unmarshal(data, &tasks); err != nil {
        return nil, fmt.Errorf("parsing tasks file: %w", err)
    }
    return tasks, nil
}

// Save writes all tasks back to disk as indented JSON.
func (s *Store) Save(tasks []Task) error {
    data, err := json.MarshalIndent(tasks, "", "  ")
    if err != nil {
        return fmt.Errorf("encoding tasks: %w", err)
    }
    if err := os.WriteFile(s.path, data, 0644); err != nil {
        return fmt.Errorf("writing tasks file: %w", err)
    }
    return nil
}

// NextID returns the smallest unused, positive ID for a new task.
func NextID(tasks []Task) int {
    max := 0
    for _, t := range tasks {
        if t.ID > max {
            max = t.ID
        }
    }
    return max + 1
}

%w in fmt.Errorf wraps the underlying error so callers can inspect the original cause with errors.Is/errors.Unwrap — you'll use this properly in Level 2, Module 4.

main.go — the CLI

// main.go
package main

import (
    "fmt"
    "os"
    "strconv"

    "todocli/todo"
)

const dataFile = "tasks.json"

func main() {
    store := todo.NewStore(dataFile)
    tasks, err := store.Load()
    if err != nil {
        fmt.Println("error loading tasks:", err)
        os.Exit(1)
    }

    if len(os.Args) < 2 {
        printUsage()
        return
    }

    command := os.Args[1]
    args := os.Args[2:]

    switch command {
    case "add":
        tasks, err = addTask(tasks, args)
    case "list":
        listTasks(tasks)
        return
    case "done":
        tasks, err = markDone(tasks, args)
    case "delete":
        tasks, err = deleteTask(tasks, args)
    default:
        printUsage()
        return
    }

    if err != nil {
        fmt.Println("error:", err)
        os.Exit(1)
    }

    if err := store.Save(tasks); err != nil {
        fmt.Println("error saving tasks:", err)
        os.Exit(1)
    }
}

func printUsage() {
    fmt.Println("Usage: todocli [add <title> | list | done <id> | delete <id>]")
}

func addTask(tasks []todo.Task, args []string) ([]todo.Task, error) {
    if len(args) != 1 {
        return tasks, fmt.Errorf("add requires exactly one quoted title")
    }
    newTask := todo.Task{
        ID:    todo.NextID(tasks),
        Title: args[0],
        Done:  false,
    }
    tasks = append(tasks, newTask)
    fmt.Printf("Added #%d: %s\n", newTask.ID, newTask.Title)
    return tasks, nil
}

func listTasks(tasks []todo.Task) {
    if len(tasks) == 0 {
        fmt.Println("No tasks yet.")
        return
    }
    for _, t := range tasks {
        status := " "
        if t.Done {
            status = "x"
        }
        fmt.Printf("[%s] #%d %s\n", status, t.ID, t.Title)
    }
}

func markDone(tasks []todo.Task, args []string) ([]todo.Task, error) {
    id, err := parseID(args)
    if err != nil {
        return tasks, err
    }
    for i := range tasks {
        if tasks[i].ID == id {
            tasks[i].Done = true
            fmt.Printf("Marked #%d done\n", id)
            return tasks, nil
        }
    }
    return tasks, fmt.Errorf("no task with id %d", id)
}

func deleteTask(tasks []todo.Task, args []string) ([]todo.Task, error) {
    id, err := parseID(args)
    if err != nil {
        return tasks, err
    }
    remaining := make([]todo.Task, 0, len(tasks))
    found := false
    for _, t := range tasks {
        if t.ID == id {
            found = true
            continue
        }
        remaining = append(remaining, t)
    }
    if !found {
        return tasks, fmt.Errorf("no task with id %d", id)
    }
    fmt.Printf("Deleted #%d\n", id)
    return remaining, nil
}

func parseID(args []string) (int, error) {
    if len(args) != 1 {
        return 0, fmt.Errorf("expected exactly one id argument")
    }
    id, err := strconv.Atoi(args[0])
    if err != nil {
        return 0, fmt.Errorf("invalid id %q: %w", args[0], err)
    }
    return id, nil
}

Running it

go run . add "Buy milk"
# Added #1: Buy milk

go run . add "Write report"
# Added #2: Write report

go run . list
# [ ] #1 Buy milk
# [ ] #2 Write report

go run . done 1
# Marked #1 done

go run . list
# [x] #1 Buy milk
# [ ] #2 Write report

go run . delete 2
# Deleted #2

go run . list
# [x] #1 Buy milk

Each invocation reloads tasks.json from disk, so tasks persist across separate runs of the program. Build a standalone binary with go build -o todocli . and ./todocli list works the same way, no go run needed.

How It Actually Works

The CLI parses os.Args directly rather than through a framework, which means os.Args[0] is the invoked binary path (platform-dependent — it can be relative, absolute, or just the binary name depending on how it was launched) and everything from os.Args[1] on is the tokens the shell handed to exec after doing its own quoting/globbing — Go never re-parses shell syntax, it just receives the already- split argv array from the OS. Reading and writing the todo file with os.ReadFile/os.WriteFile each does a single open/read-all/close (or open/write/close) syscall sequence with no partial-write protection — a crash mid-WriteFile on some filesystems can leave a truncated file, which is why production tools instead write to a temp file and os.Rename it into place (rename is atomic on POSIX filesystems because it only swaps a directory entry, not file contents). JSON marshaling walks the struct via reflection at runtime — encoding/ json inspects field tags and types through the reflect package for every call, which is measurably slower than the escape-analysis-driven direct field access used elsewhere in the codebase, and is exactly why high-throughput services often generate marshal code instead (see level-2/05).

Stretch goals

  • Add a priority field (low/medium/high) and sort list output by it.
  • Add a --filter=done / --filter=pending flag to list (you'll build proper flag parsing with the flag package in Level 3, Module 8).
  • Write unit tests for NextID and the add/done/delete logic (formalized with the testing package in Level 2, Module 6).
  • Switch from a full-file rewrite on every save to an append-only log file.

Completing this project means you're ready for Level 2 · Intermediate.

🔀 See this in another language