LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
RubyBeginner~1.5 hours

Contact Book CLI

Build a menu-driven contact book that adds, lists, searches and deletes contacts and saves them to a JSON file.

HashesArraysBlocksJSON Files

Overview

A contact book is a natural second Ruby project: each contact is a hash ({ name:, phone:, email: }), the whole book is an array of hashes, and almost every feature is a one-line block — select to search, sort_by! to keep names in order, find and delete to remove. It is a hands-on tour of Ruby's collection methods from lesson 10.

Unlike the guessing game, this program remembers things. Contacts are saved to contacts.json after every change using Ruby's built-in json library, so they are still there the next time you run the program.

What You'll Build
  • load_contacts and save_contacts methods that read and write a JSON file with symbol keys.
  • An add_contact flow that rejects empty names, duplicate names and invalid phone numbers.
  • A formatted list_contacts table built with format and each.with_index.
  • Case-insensitive search by name or phone number with select.
  • Case-insensitive deletion with find and delete, and a numbered menu driven by case/when.

Prerequisites

  • Methods and blocks — lessons 7 and 8.
  • Arrays and hashes, including select, any? and sort_by — lesson 10.
  • Requiring a standard library with require — lesson 11.
  • The Number Guessing Game project (recommended, for gets and loops).

Project Structure

The program is one file, contacts.rb, plus the contacts.json data file it creates. A single contacts array is loaded once at startup and passed into each feature method; every method that changes the array calls save_contacts immediately, so the file on disk never falls out of sync with what you see on screen.

Step 1: Load & Save Contacts as JSON

JSON.parse(..., symbolize_names: true) turns the JSON keys back into symbols, so the rest of the program can always write contact[:name]. The rescue JSON::ParserError clause on the method body means a hand-edited, broken file produces a friendly warning instead of a crash.

require "json"
FILE = "contacts.json"
def load_contacts
return [] unless File.exist?(FILE)
JSON.parse(File.read(FILE), symbolize_names: true)
rescue JSON::ParserError
puts "Warning: #{FILE} is not valid JSON, starting with an empty book."
[]
end
def save_contacts(contacts)
File.write(FILE, JSON.pretty_generate(contacts))
end
def prompt(label)
print label
(gets || "").chomp.strip # treat end of input as an empty answer
end

Step 2: Add a Contact with Validation

casecmp? compares strings ignoring case, so "asha" and "Asha" count as the same name. The regular expression accepts digits, spaces, dashes and an optional leading +, and requires at least seven characters — loose enough for real numbers like +91 98765 43210, strict enough to catch typos like abc.

PHONE_FORMAT = /\A\+?[\d\s-]{7,}\z/
def add_contact(contacts)
name = prompt("Name: ")
if name.empty?
puts "Name can't be empty."
return
end
if contacts.any? { |c| c[:name].casecmp?(name) }
puts "#{name} is already in your contacts."
return
end
phone = prompt("Phone: ")
unless phone.match?(PHONE_FORMAT)
puts "That doesn't look like a phone number."
return
end
email = prompt("Email (optional): ")
contacts << { name: name, phone: phone, email: email }
contacts.sort_by! { |c| c[:name].downcase }
save_contacts(contacts)
puts "Saved #{name}."
end

Step 3: List & Search with Blocks

format("%2d. %-12s %s", ...) pads the number and name into neat columns: %-12s means "a string, left-aligned in 12 characters". Search reuses list_contacts for its output — select returns a new, smaller array, which is exactly what list_contacts already knows how to print.

def list_contacts(contacts)
if contacts.empty?
puts "No contacts to show."
return
end
contacts.each.with_index(1) do |c, number|
line = format("%2d. %-12s %s", number, c[:name], c[:phone])
line += " <#{c[:email]}>" unless c[:email].empty?
puts line
end
end
def search_contacts(contacts)
term = prompt("Search for: ").downcase
matches = contacts.select do |c|
c[:name].downcase.include?(term) || c[:phone].include?(term)
end
puts "#{matches.size} match(es):"
list_contacts(matches)
end

Step 4: Delete & the Menu Loop

find returns the first contact whose name matches (ignoring case) or nil if there is none, so it doubles as a "was it found?" check — and because it returns the stored contact, the confirmation message shows the name exactly as it was saved, even if you typed it in lower case. In the menu, an empty answer (including end of input) quits, which guarantees the loop can never spin forever.

def delete_contact(contacts)
name = prompt("Name to delete: ")
contact = contacts.find { |c| c[:name].casecmp?(name) }
if contact
contacts.delete(contact)
save_contacts(contacts)
puts "Deleted #{contact[:name]}." # the stored spelling, not what was typed
else
puts "No contact named #{name}."
end
end
contacts = load_contacts
puts "Loaded #{contacts.size} contact(s) from #{FILE}."
loop do
puts
puts "=== CONTACT BOOK ==="
puts "1. Add 2. List 3. Search 4. Delete 5. Quit"
case prompt("Choose: ")
when "1" then add_contact(contacts)
when "2" then list_contacts(contacts)
when "3" then search_contacts(contacts)
when "4" then delete_contact(contacts)
when "5", ""
puts "Goodbye!"
break
else
puts "Please choose a number from 1 to 5."
end
end

Complete Code

contacts.rb
# contacts.rb — run with: ruby contacts.rb
require "json"
FILE = "contacts.json"
PHONE_FORMAT = /\A\+?[\d\s-]{7,}\z/
def load_contacts
return [] unless File.exist?(FILE)
JSON.parse(File.read(FILE), symbolize_names: true)
rescue JSON::ParserError
puts "Warning: #{FILE} is not valid JSON, starting with an empty book."
[]
end
def save_contacts(contacts)
File.write(FILE, JSON.pretty_generate(contacts))
end
def prompt(label)
print label
(gets || "").chomp.strip # treat end of input as an empty answer
end
def add_contact(contacts)
name = prompt("Name: ")
if name.empty?
puts "Name can't be empty."
return
end
if contacts.any? { |c| c[:name].casecmp?(name) }
puts "#{name} is already in your contacts."
return
end
phone = prompt("Phone: ")
unless phone.match?(PHONE_FORMAT)
puts "That doesn't look like a phone number."
return
end
email = prompt("Email (optional): ")
contacts << { name: name, phone: phone, email: email }
contacts.sort_by! { |c| c[:name].downcase }
save_contacts(contacts)
puts "Saved #{name}."
end
def list_contacts(contacts)
if contacts.empty?
puts "No contacts to show."
return
end
contacts.each.with_index(1) do |c, number|
line = format("%2d. %-12s %s", number, c[:name], c[:phone])
line += " <#{c[:email]}>" unless c[:email].empty?
puts line
end
end
def search_contacts(contacts)
term = prompt("Search for: ").downcase
matches = contacts.select do |c|
c[:name].downcase.include?(term) || c[:phone].include?(term)
end
puts "#{matches.size} match(es):"
list_contacts(matches)
end
def delete_contact(contacts)
name = prompt("Name to delete: ")
contact = contacts.find { |c| c[:name].casecmp?(name) }
if contact
contacts.delete(contact)
save_contacts(contacts)
puts "Deleted #{contact[:name]}." # the stored spelling, not what was typed
else
puts "No contact named #{name}."
end
end
contacts = load_contacts
puts "Loaded #{contacts.size} contact(s) from #{FILE}."
loop do
puts
puts "=== CONTACT BOOK ==="
puts "1. Add 2. List 3. Search 4. Delete 5. Quit"
case prompt("Choose: ")
when "1" then add_contact(contacts)
when "2" then list_contacts(contacts)
when "3" then search_contacts(contacts)
when "4" then delete_contact(contacts)
when "5", ""
puts "Goodbye!"
break
else
puts "Please choose a number from 1 to 5."
end
end

Sample Run

ruby contacts.rb

Click Run to see what this code prints.

After quitting, contacts.json holds the saved data — run the program again and it starts with "Loaded 2 contact(s)":

contacts.json

Click Run to see what this code prints.

Extend This Project

  • Add an "Edit" option that updates a contact's phone or email without deleting and re-adding it.
  • Validate emails with a simple pattern such as /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/.
  • Add groups or tags (tags: ["family", "work"]) and a "list by tag" option using select.
  • Export contacts to CSV with Ruby's csv library (see the Expense Tracker project).
  • Refactor the methods into a ContactBook class (lesson 9) that owns the array and the file name.

Summary

You built a persistent command-line app around an array of hashes, using blocks for every collection operation — any? to detect duplicates, sort_by! to keep order, select to search and find with delete to remove — and Ruby's json library to save data between runs. This "load, change, save" cycle is the backbone of countless real scripts and tools.