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¶
- Commands:
add <title>,list,done <n>,undo <n>,remove <n>,clear(delete finished tasks). - Tasks are numbered from 1 in the display and persist to a text file (
tasks.txt, or the path in theTASKS_FILEenvironment variable) after every change. - Bad input is reported with a friendly message on standard error and exit code
1— never a crash. - A missing storage file means "no tasks yet", not an error.
- A corrupt storage file is reported with the line number.
Setup¶
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 tinystructbundling a title and a done flag. (Astructis 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|titleor1|title. Simple, human-readable, easy to debug. The|is split withmaxSplits: 1so titles may contain|. - Commands — one
runfunction with aswitchon the first argument. - Entry point — a single
do/catchat 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: " ")letsadd Buy milkwork 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 bothdoneandundoin one case.try text.write(toFile:atomically:encoding:)withatomically: truewrites 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.txtand runlist— no crash. - [ ] Hand-edit
tasks.txtto containgarbageand see a line-numbered error.
Stretch goals¶
- Priorities:
add -p high Fix bug; show tasks sorted by priority. (Model priority as anenum— try it before reading about enums in Intermediate!) edit <n> <new title>command.- Due dates using
DateandDateFormatter(Foundation); show overdue tasks first. list --done/list --todofilters usingCommandLine.argumentsflags.- JSON storage instead of the text file — after Intermediate lesson 9 (Codable) this takes about ten lines.
- 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.