10 · Project — CLI To-Do App¶
A small end-to-end project combining everything from Level 1: val/var,
control flow, functions, collections, case classes, pattern matching, and
traits.
What you'll build¶
A command-line to-do list that:
- Adds tasks
- Lists tasks (with done/pending status)
- Marks tasks done
- Deletes tasks
- Persists everything to a plain text file between runs
We use a simple pipe-delimited text file for storage rather than JSON, since a proper JSON library is introduced in Level 2 · Working with JSON. Parsing a one-line-per-task format by hand is a nice, self-contained exercise in string processing.
Project layout¶
todo-app/
├── build.sbt
└── src/
└── main/
└── scala/
├── Task.scala
├── TaskStorage.scala
└── Main.scala
// build.sbt
ThisBuild / scalaVersion := "3.3.3"
lazy val root = (project in file("."))
.settings(
name := "todo-app"
)
Task.scala — the data model¶
// src/main/scala/Task.scala
case class Task(description: String, done: Boolean = false):
def toLine: String =
s"${if done then "1" else "0"}|$description"
object Task:
def fromLine(line: String): Option[Task] =
line.split("\\|", 2) match
case Array(doneFlag, description) =>
Some(Task(description, doneFlag == "1"))
case _ =>
None
Task.fromLine returns Option[Task] — None for a malformed line instead
of crashing. Option gets a full treatment in
Level 2; for now, read Some(x) as "a
value is present" and None as "nothing here," and note that fromLine
lets the caller decide what to do about missing/bad data rather than
throwing.
TaskStorage.scala — persistence layer¶
// src/main/scala/TaskStorage.scala
import scala.io.Source
import java.io.PrintWriter
import java.io.File
class TaskStorage(path: String = "tasks.txt"):
def load(): List[Task] =
val file = File(path)
if !file.exists() then List.empty
else
val source = Source.fromFile(file)
try
source.getLines()
.map(Task.fromLine)
.collect { case Some(task) => task } // drop any malformed lines
.toList
finally
source.close()
def save(tasks: List[Task]): Unit =
val writer = PrintWriter(File(path))
try
tasks.foreach(task => writer.println(task.toLine))
finally
writer.close()
collect { case Some(task) => task } combines filtering and unwrapping in
one pass — it keeps only the Some results and extracts their contents,
skipping any Nones from malformed lines entirely.
Main.scala — CLI logic¶
// src/main/scala/Main.scala
def addTask(tasks: List[Task], description: String): List[Task] =
tasks :+ Task(description)
def listTasks(tasks: List[Task]): Unit =
if tasks.isEmpty then println("No tasks yet.")
else
tasks.zipWithIndex.foreach { case (task, i) =>
val status = if task.done then "x" else " "
println(s"[$status] ${i + 1}. ${task.description}")
}
def completeTask(tasks: List[Task], index: Int): List[Task] =
if index < 1 || index > tasks.length then
println(s"No task with number $index")
tasks
else
tasks.zipWithIndex.map { case (task, i) =>
if i == index - 1 then task.copy(done = true) else task
}
def deleteTask(tasks: List[Task], index: Int): List[Task] =
if index < 1 || index > tasks.length then
println(s"No task with number $index")
tasks
else
println(s"Deleted: ${tasks(index - 1).description}")
tasks.zipWithIndex.filter { case (_, i) => i != index - 1 }.map(_._1)
@main def todo(args: String*): Unit =
val storage = TaskStorage()
val tasks = storage.load()
args.toList match
case Nil =>
println("Usage: todo [add <text> | list | done <n> | delete <n>]")
case "add" :: rest if rest.nonEmpty =>
val updated = addTask(tasks, rest.mkString(" "))
storage.save(updated)
println(s"Added: ${rest.mkString(" ")}")
case "add" :: Nil =>
println("Usage: todo add <text>")
case "list" :: _ =>
listTasks(tasks)
case "done" :: nStr :: _ =>
val updated = completeTask(tasks, nStr.toIntOption.getOrElse(0))
storage.save(updated)
case "delete" :: nStr :: _ =>
val updated = deleteTask(tasks, nStr.toIntOption.getOrElse(0))
storage.save(updated)
case command :: _ =>
println(s"Unknown command: $command")
The match on args.toList is doing a lot of work here: "add" :: rest
destructures the list into its first element and everything after, nStr.toIntOption
safely parses a string to an Int (returning None instead of throwing on
bad input), and the final case command :: _ catches anything unrecognized.
Running it¶
sbt run add "Write Level 1 exercises"
sbt run add "Review Level 2 outline"
sbt run list
# [ ] 1. Write Level 1 exercises
# [ ] 2. Review Level 2 outline
sbt run done 1
sbt run list
# [x] 1. Write Level 1 exercises
# [ ] 2. Review Level 2 outline
(Passing arguments through sbt run requires quoting the whole command as
one line, e.g. sbt "run add \"Write Level 1 exercises\"", or building a
standalone jar with sbt package and running it with scala directly — sbt
argument-passing quirks are covered in
Level 2 · sbt Deep Dive.)
Stretch goals¶
- Add a
priorityfield (Low/Medium/High) modeled as asealed trait(see Module 9) and sort the list by it. - Add a
dueDatefield usingjava.time.LocalDateand highlight overdue tasks when listing. - Write a basic test for
TaskStorageby hand (round-trip a list of tasks throughsave/loadand assert equality) — you'll formalize this properly with ScalaTest in Level 2 · Testing with ScalaTest.
How It Actually Works¶
This project quietly exercises three separate compile-time mechanisms
you've now met individually. The Task case class gets its equals,
hashCode, toString, and copy generated by the compiler (see Module
7) — that's what makes "mark a task done without
mutating the original list" work as tasks.map(t => if t.id == id then
t.copy(done = true) else t) rather than requiring hand-written update
logic. TaskStorage's save/load round-trip through a plain-text format
relies on toString's deterministic field ordering (generated the same
way for every case class) to serialize, and pattern matching or split
parsing to deserialize — there's no reflection involved, just string
formatting.
The CLI's command dispatch (match on the first argument) compiles to the
instanceof/string-equals chain or lookupswitch described in Module
8, and the whole Main.scala entry point
compiles down to a class with a public static void main(String[] args)
method the JVM launcher can find and call, exactly as in Module
1 — args is literally the JVM's own String[] args
threaded through, which is why command-line arguments arrive as a plain
Array[String] rather than anything Scala-specific.
Completing this project means you're ready for Level 2 · Intermediate.