Skip to content

12. Capstone: command-line task tracker

Goal

Build a real command-line app that stores data on disk between runs. It pulls together almost everything in Beginner: variables, strings, arrays, switch, functions, optionals, error handling, and file I/O. It also gives you a first taste of a struct, which the Intermediate level covers properly.

What it does

$ swift run tasks add Buy milk
Added #1: Buy milk
$ swift run tasks add Learn Swift
Added #2: Learn Swift
$ swift run tasks done 2
1. [ ] Buy milk
2. [x] Learn Swift
1/2 done
$ swift run tasks remove 9
no task #9 (you have 2)
$ echo $?
1

Requirements

  1. Commands: add <title>, list, done <n>, undo <n>, remove <n>, clear (delete finished tasks).
  2. Tasks are numbered from 1 in the display and persist to a text file (tasks.txt, or the path in the TASKS_FILE environment variable) after every change.
  3. Bad input is reported with a friendly message on standard error and exit code 1 — never a crash.
  4. A missing storage file means "no tasks yet", not an error.
  5. A corrupt storage file is reported with the line number.

Setup

mkdir tasks && cd tasks
swift package init --type executable --name tasks

Replace Package.swift with:

// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "tasks",
    targets: [
        .executableTarget(name: "tasks")
    ]
)

Then put the code below in Sources/tasks/main.swift (older toolchains may generate Sources/main.swift — put it wherever the generated main.swift lives).

The design

  • TodoItem — a tiny struct bundling a title and a done flag. (A struct is a custom type with named fields; you'll study them next level.)
  • TrackerError — one enum for everything that can go wrong, with friendly messages.
  • Storage (loadTasks, saveTasks) — a text format with one task per line, 0|title or 1|title. Simple, human-readable, easy to debug. The | is split with maxSplits: 1 so titles may contain |.
  • Commands — one run function with a switch on the first argument.
  • Entry point — a single do/catch at the bottom turns every thrown error into a message and an exit code.

Notice the layering: storage knows nothing about commands, and commands know nothing about the file format.

The full program

import Foundation

// MARK: - Model

struct TodoItem {
    var title: String
    var isDone: Bool
}

// MARK: - Errors

enum TrackerError: LocalizedError {
    case usage(String)
    case badNumber(String)
    case outOfRange(Int, count: Int)
    case corruptLine(Int, String)

    var errorDescription: String? {
        switch self {
        case .usage(let message):
            return message
        case .badNumber(let text):
            return "'\(text)' is not a task number"
        case .outOfRange(let n, let count):
            return "no task #\(n) (you have \(count))"
        case .corruptLine(let lineNumber, let line):
            return "tasks file is corrupt at line \(lineNumber): \(line)"
        }
    }
}

// MARK: - Storage

func storagePath() -> String {
    ProcessInfo.processInfo.environment["TASKS_FILE"] ?? "tasks.txt"
}

/// File format: one task per line, "0|title" or "1|title".
func loadTasks() throws -> [TodoItem] {
    guard FileManager.default.fileExists(atPath: storagePath()) else { return [] }
    let text = try String(contentsOfFile: storagePath(), encoding: .utf8)
    var tasks: [TodoItem] = []
    for (index, line) in text.split(separator: "\n").enumerated() {
        let parts = line.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
        guard parts.count == 2, parts[0] == "0" || parts[0] == "1" else {
            throw TrackerError.corruptLine(index + 1, String(line))
        }
        tasks.append(TodoItem(title: String(parts[1]), isDone: parts[0] == "1"))
    }
    return tasks
}

func saveTasks(_ tasks: [TodoItem]) throws {
    let lines = tasks.map { "\($0.isDone ? 1 : 0)|\($0.title)" }
    let text = lines.joined(separator: "\n") + (lines.isEmpty ? "" : "\n")
    try text.write(toFile: storagePath(), atomically: true, encoding: .utf8)
}

// MARK: - Commands

func taskNumber(_ text: String, in tasks: [TodoItem]) throws -> Int {
    guard let n = Int(text) else { throw TrackerError.badNumber(text) }
    guard n >= 1, n <= tasks.count else {
        throw TrackerError.outOfRange(n, count: tasks.count)
    }
    return n - 1       // convert to a 0-based index
}

func printList(_ tasks: [TodoItem]) {
    if tasks.isEmpty {
        print("Nothing to do. Add something with: add <title>")
        return
    }
    for (index, task) in tasks.enumerated() {
        let box = task.isDone ? "[x]" : "[ ]"
        print("\(index + 1). \(box) \(task.title)")
    }
    let doneCount = tasks.filter(\.isDone).count
    print("\(doneCount)/\(tasks.count) done")
}

func usage() -> String {
    """
    usage: tasks <command>

      add <title>     add a task
      list            show all tasks
      done <n>        mark task n as done
      undo <n>        mark task n as not done
      remove <n>      delete task n
      clear           delete all finished tasks
    """
}

func run(_ args: [String]) throws {
    guard let command = args.first else { throw TrackerError.usage(usage()) }
    let rest = Array(args.dropFirst())
    var tasks = try loadTasks()

    switch command {
    case "add":
        let title = rest.joined(separator: " ").trimmingCharacters(in: .whitespaces)
        guard !title.isEmpty else { throw TrackerError.usage("usage: tasks add <title>") }
        tasks.append(TodoItem(title: title, isDone: false))
        try saveTasks(tasks)
        print("Added #\(tasks.count): \(title)")

    case "list":
        printList(tasks)

    case "done", "undo":
        guard let arg = rest.first else { throw TrackerError.usage("usage: tasks \(command) <n>") }
        let i = try taskNumber(arg, in: tasks)
        tasks[i].isDone = (command == "done")
        try saveTasks(tasks)
        printList(tasks)

    case "remove":
        guard let arg = rest.first else { throw TrackerError.usage("usage: tasks remove <n>") }
        let i = try taskNumber(arg, in: tasks)
        let removed = tasks.remove(at: i)
        try saveTasks(tasks)
        print("Removed: \(removed.title)")

    case "clear":
        let before = tasks.count
        tasks.removeAll(where: \.isDone)
        try saveTasks(tasks)
        print("Cleared \(before - tasks.count) finished task(s)")

    default:
        throw TrackerError.usage("unknown command '\(command)'\n\n\(usage())")
    }
}

// MARK: - Entry point

do {
    try run(Array(CommandLine.arguments.dropFirst()))
} catch {
    FileHandle.standardError.write(Data("\(error.localizedDescription)\n".utf8))
    exit(1)
}

Try it

swift build
export TASKS_FILE=./demo.txt        # keep experiments out of your real list (PowerShell: $env:TASKS_FILE="./demo.txt")
swift run tasks add Buy milk
swift run tasks add "Write the capstone"
swift run tasks done 1
swift run tasks list
swift run tasks remove 7            # friendly error, exit code 1

Walkthrough of the tricky parts

  • rest.joined(separator: " ") lets add Buy milk work without quotes: the shell splits it into two arguments and we glue them back together.
  • guard parts.count == 2, parts[0] == "0" || parts[0] == "1" else { throw ... } validates a line before trusting it. Never index into data you haven't checked.
  • taskNumber(_:in:) is the only place that converts user input to an index. Every command shares that one safe path: parse (optional), range-check, subtract 1.
  • tasks[i].isDone = (command == "done") handles both done and undo in one case.
  • try text.write(toFile:atomically:encoding:) with atomically: true writes to a temp file and renames it, so a crash mid-write can't leave you with half a file.

Self-check

Before you call it finished, make sure you can:

  • [ ] Explain why Int("abc") can't crash this program.
  • [ ] Add a task whose title contains | and see it round-trip correctly.
  • [ ] Delete tasks.txt and run list — no crash.
  • [ ] Hand-edit tasks.txt to contain garbage and see a line-numbered error.

Stretch goals

  1. Priorities: add -p high Fix bug; show tasks sorted by priority. (Model priority as an enum — try it before reading about enums in Intermediate!)
  2. edit <n> <new title> command.
  3. Due dates using Date and DateFormatter (Foundation); show overdue tasks first.
  4. list --done / list --todo filters using CommandLine.arguments flags.
  5. JSON storage instead of the text file — after Intermediate lesson 9 (Codable) this takes about ten lines.
  6. Undo the last change by keeping a history of previous task arrays (value types make this nearly free!).

When you're done

You can read, write, and organise a small Swift program, handle bad input and I/O failures gracefully, and persist data. Go to Intermediate — structs, classes, protocols, generics, closures, and memory management are next.