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
- A Task model class with state and to_s
- A TaskList managing a collection of tasks
- Filtering and summarising with Enumerable
- Safe operations with exception handling
- Saving and loading tasks as JSON files
- A command dispatcher loop
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 Ruby2️⃣ 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 milkYour 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 completeFinal 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 hereCommon Errors (and the fix)
- "uninitialized constant Task" — Define Task above TaskList in the same file; Ruby reads top to bottom.
- JSON keys mismatch — You saved with string keys ("title") so read them back with string keys, not symbols.
- IndexError on a bad number — Use fetch with a guard, and convert input with to_i before indexing.
- reject vs reject! — reject returns a new array; reject! mutates @tasks in place — choose intentionally.
- tasks.json not found — Run the save step before load, and stay in the same folder so the relative path matches.
Pro Tips
- 💡 Make it interactive: swap the fake input array for a loop reading gets.chomp until "quit".
- 💡 Add tests: with what you know, an RSpec or Minitest suite for TaskList is a great next step.
- 💡 Package it: turn the script into a gem with an executable so others can install your CLI.
📋 Quick Reference — Skills Used
| Lesson | Used For |
|---|---|
| Classes / Attributes | Task, attr_accessor |
| Arrays | @tasks << task |
| Enumerable | reject, count, map |
| Exceptions | fetch / raise |
| File I/O + Gems | File.write, JSON |
| Metaprogramming | command dispatch |
🎉 Course Complete!
- ✅ You built a real CLI app from classes, collections, and blocks
- ✅ You filtered and summarised data with Enumerable
- ✅ You made operations safe with exception handling
- ✅ You persisted data to JSON files on disk
- ✅ You combined every lesson into one working program
- 🎓 Congratulations — you've finished the Ruby course! Keep building: try Rails, package a gem, or practice on exercism.org.
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
- Previous: Checkpoint: Advanced Ruby
- Next: Intro to Rails — Meet the Rails web framework and the MVC pattern
- Quick reference: Ruby cheat sheet
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.