Skip to content

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 priority field (Low/Medium/High) modeled as a sealed trait (see Module 9) and sort the list by it.
  • Add a dueDate field using java.time.LocalDate and highlight overdue tasks when listing.
  • Write a basic test for TaskStorage by hand (round-trip a list of tasks through save/load and 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 1args 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.

🔀 See this in another language