Capstone: A CLI App

Reviewed & published by Brayan K

Ruby is a dynamic, beginner-friendly programming language, and in this capstone you'll combine everything you've learned to build a real command-line To-Do application from scratch.

Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

By the end of this lesson you'll have assembled classes, collections, blocks, exceptions, JSON, and file I/O into one working program — proof that the pieces fit together.

What You'll Build in This Capstone

1️⃣ The Task Model

Start with the smallest unit: a single Task. It holds a title and a done flag, toggles its state with a bang method, and defines to_s so it prints as a tidy checkbox line. to_h prepares it for JSON saving later.

# CAPSTONE: a command-line To-Do app. We'll build it piece by piece,
# using classes, arrays, hashes, blocks, exceptions, and file I/O.

require "json"

# 1) The MODEL: a single Task. (Classes & attr_accessor lesson.)
class Task
  attr_accessor :title, :done

  def initialize(title, done = false)
    @title = title
    @done = done
  end

  def toggle!
    @done = !@done   # ! by convention: this mutates the object
  end

  # to_s controls how a task prints (Classes lesson).
  def to_s
    "[#{@done ? "x" : " "}] #{@title}"
  end

  # Convert to/from a plain hash so we can save it as JSON.
  def to_h
    { "title" => @title, "done" => @done }
  end
end

t = Task.new("Learn Ruby")
puts t            # -> [ ] Learn Ruby
t.toggle!
puts t            # -> [x] Learn Ruby

2️⃣ The TaskList Collection

Next, the TaskList manages many tasks. It adds with <<, completes safely with fetch and raise, filters pending items with reject(&:done), and summarises with count — pure Enumerable.

# 2) The TaskList manages many tasks (Arrays, Enumerable, blocks).
class TaskList
  def initialize
    @tasks = []
  end

  def add(title)
    @tasks << Task.new(title)   # << from the Arrays lesson
  end

  def complete(index)
    # Exception handling: guard against a bad index.
    task = @tasks.fetch(index) { raise IndexError, "No task ##{index}" }
    task.toggle!
  end

  def pending
    @tasks.reject(&:done)       # Enumerable + symbol-to-proc
  end

  def render
    @tasks.each_with_index do |task, i|
      puts "#{i}: #{task}"      # uses Task#to_s
    end
  end

  def summary
    done = @tasks.count(&:done)
    "#{done}/#{@tasks.length} complete"
  end
end

list = TaskList.new
list.add("Write code")
list.add("Test it")
list.add("Ship it")
list.complete(0)
list.render
puts list.summary             # -> 1/3 complete
puts "Pending: #{list.pending.map(&:title).inspect}"

3️⃣ Persistence with JSON Files

Finally, make tasks survive between runs. save maps tasks to hashes and writes them with File.write and JSON.pretty_generate; load reads them back, guarding with File.exist?. (Ruby's open classes let us reopen TaskList to add methods.)

# 3) Persistence + a command loop (File I/O, Hashes, send).
class TaskList
  FILE = "tasks.json"

  def save
    data = @tasks.map(&:to_h)
    File.write(FILE, JSON.pretty_generate(data))   # File I/O lesson
    "Saved #{@tasks.length} tasks"
  end

  def load
    return "No saved tasks" unless File.exist?(FILE)
    JSON.parse(File.read(FILE)).each do |h|
      @tasks << Task.new(h["title"], h["done"])
    end
    "Loaded #{@tasks.length} tasks"
  end
end

# A simple dispatcher routes a command word to a method (Metaprogramming).
list = TaskList.new
list.add("Buy milk")
puts list.save                 # -> Saved 1 tasks

fresh = TaskList.new
puts fresh.load                # -> Loaded 1 tasks
fresh.render                   # -> 0: [ ] Buy milk

Your turn. Extend TaskList with a safe remove and a clear_done. Confirm the TODOs, combine with the classes above, then run it.

# 🎯 YOUR TURN — extend the app, then run with: ruby todo_app.rb
# (Combine the Task and TaskList classes above into one file first.)

class TaskList
  # 1) Add a method that removes a task by index, safely.
  def remove(index)
    @tasks.delete_at(index) or raise IndexError, "No task ##{index}"  # 👉 TODO
  end

  # 2) Add a method that clears all COMPLETED tasks, keeping pending ones.
  def clear_done
    @tasks.reject!(&:done)     # 👉 TODO: keep only pending tasks
    "Cleared completed tasks"
  end
end

list = TaskList.new
list.add("Task A")
list.add("Task B")
list.complete(0)
list.clear_done
list.render                    # only Task B should remain
puts list.summary

# ✅ Expected output:
#    0: [ ] Task B
#    0/1 complete

Final Challenge: A Command Loop

Tie it all together with a tiny menu that dispatches commands to your TaskList — the final step toward a real interactive CLI. Run with ruby cli.rb.

# 🎯 FINAL CHALLENGE: a mini command loop.
# Save as cli.rb and run with: ruby cli.rb
#
# Build a tiny text menu around your TaskList:
# 1. Use a Hash of commands => descriptions, e.g. {"add" => "...", "list" => "..."}.
# 2. Loop over a fixed array of fake inputs: ["add Walk dog", "list", "done 0", "list"].
# 3. For each input, split into a command word and the rest.
# 4. Use a case statement (or send) to call the right TaskList method.
# 5. Print the result of each command.
#
# ✅ Example output:
#    Added: Walk dog
#    0: [ ] Walk dog
#    Completed task 0
#    0: [x] Walk dog

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Skills Used

LessonUsed For
Classes / AttributesTask, attr_accessor
Arrays@tasks << task
Enumerablereject, count, map
Exceptionsfetch / raise
File I/O + GemsFile.write, JSON
Metaprogrammingcommand dispatch

🎉 Course Complete!

Practice quiz

In the Task class, what does the toggle! method do?

  • Deletes the task
  • Renames the task
  • Flips @done to its opposite with @done = !@done
  • Saves the task to disk

Answer: Flips @done to its opposite with @done = !@done. toggle! sets @done = !@done, switching the completed state; the ! marks that it mutates.

Why does the Task class define a custom to_s method?

  • To control how the task prints with puts and string interpolation
  • To convert the task to JSON
  • Because Ruby requires every class to define to_s
  • To make the task comparable

Answer: To control how the task prints with puts and string interpolation. to_s controls how an object renders when printed, e.g. the checkbox line [x] Learn Ruby.

TaskList#add uses @tasks << Task.new(title). What does << do here?

  • Compares two tasks
  • Removes a task
  • Shifts bits
  • Appends the new Task to the @tasks array

Answer: Appends the new Task to the @tasks array. << (the shovel operator) appends the new Task object to the end of the @tasks array.

complete(index) uses @tasks.fetch(index) { raise IndexError, ... }. Why fetch with a block?

  • fetch is faster than []
  • To safely handle a bad index by raising a clear error instead of returning nil
  • fetch sorts the array first
  • Because [] is not allowed on arrays

Answer: To safely handle a bad index by raising a clear error instead of returning nil. fetch with a block runs the block (raising IndexError) when the index is out of range, guarding against bad input.

pending uses @tasks.reject(&:done). What does it return?

  • The tasks whose done is falsey (the not-yet-done ones)
  • All completed tasks
  • The first task only
  • The number of tasks

Answer: The tasks whose done is falsey (the not-yet-done ones). reject keeps elements for which the block is false, so reject(&:done) returns the pending tasks.

What does the &:done in reject(&:done) and count(&:done) represent?

  • A frozen symbol
  • A reference to the @done variable
  • Symbol-to-proc: it calls the done method on each element
  • A bitwise AND

Answer: Symbol-to-proc: it calls the done method on each element. &:done converts the symbol into a proc that calls .done on each element.

Persistence uses JSON.pretty_generate and File.write. What is their combined job?

  • To print tasks to the screen
  • To serialize the tasks to a JSON string and write it to a file on disk
  • To delete the tasks file
  • To compare two task lists

Answer: To serialize the tasks to a JSON string and write it to a file on disk. pretty_generate turns the data into JSON text and File.write saves it so tasks survive between runs.

load guards with: return ... unless File.exist?(FILE). Why?

  • To avoid loading twice
  • To freeze the file
  • To sort the tasks
  • To avoid an error when the save file doesn't exist yet

Answer: To avoid an error when the save file doesn't exist yet. Checking File.exist? first prevents trying to read a file that isn't there yet.

The capstone reopens class TaskList a second time to add save/load. This is possible because Ruby classes are:

  • Sealed after first definition
  • Open — you can reopen a class to add more methods
  • Compiled to bytecode and locked
  • Single-method only

Answer: Open — you can reopen a class to add more methods. Ruby classes are open, so you can reopen TaskList later and add methods to it.

Why split the app into separate Task and TaskList classes?

  • Ruby requires at least two classes per file
  • To make the program run faster
  • Single responsibility — Task models one item, TaskList manages the collection
  • Because JSON needs two classes

Answer: Single responsibility — Task models one item, TaskList manages the collection. Separating concerns keeps each class small: Task owns one item, TaskList owns the collection.

Continue this course

Frequently asked questions

How does this capstone tie the whole course together?

Every layer uses skills from earlier lessons. The Task class uses classes, attr_accessor, and a custom to_s. The TaskList uses arrays (<<), Enumerable methods (reject, count, map) with symbol-to-proc, and blocks. Persistence uses File I/O and the json gem. Safety comes from exception handling with fetch and raise, and the command loop echoes the dispatcher idea from metaprogramming. Building one coherent app from these pieces is exactly how real Ruby programs come together.

Why structure the app as separate Task and TaskList classes?

It follows the single-responsibility principle. Task knows about one item — its title, done state, and how to display itself. TaskList manages the collection — adding, completing, filtering, saving, and loading. Keeping them apart makes each class small and easy to reason about, and lets you change one without breaking the other. This separation is the same instinct behind Rails models and the broader idea that each object should do one job well.

How would I turn the fake input loop into a real interactive CLI?

Replace the fixed array of inputs with a loop that reads real keyboard input using gets.chomp, and keep looping until the user types 'quit'. Inside the loop you split the line into a command and arguments, dispatch to the right method, and print the result — the same logic, just driven by live input. From there you could add option parsing with the built-in OptionParser, or package the script as a gem with an executable so users can install and run it by name.

Where should I go after finishing this course?

You now have solid Ruby fundamentals, so pick a direction and build. For web development, work through the official Rails Guides and build a small CRUD app. For tooling, package a CLI as a gem and publish it. To deepen the language, read 'The Ruby Programming Language' or 'Eloquent Ruby', and practice on exercism.org or Advent of Code. Most importantly, keep building real projects — the things you struggle with in a real app are what turn knowledge into skill.