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.
load_contactsandsave_contactsmethods that read and write a JSON file with symbol keys.- An
add_contactflow that rejects empty names, duplicate names and invalid phone numbers. - A formatted
list_contactstable built withformatandeach.with_index. - Case-insensitive search by name or phone number with
select. - Case-insensitive deletion with
findanddelete, and a numbered menu driven bycase/when.
Prerequisites
- Methods and blocks — lessons 7 and 8.
- Arrays and hashes, including
select,any?andsort_by— lesson 10. - Requiring a standard library with
require— lesson 11. - The Number Guessing Game project (recommended, for
getsand 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 answerendStep 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}."endStep 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 endend
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)endStep 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}." endend
contacts = load_contactsputs "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." endendComplete Code
# contacts.rb — run with: ruby contacts.rbrequire "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 answerend
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 endend
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}." endend
contacts = load_contactsputs "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." endendSample Run
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)":
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 usingselect. - Export contacts to CSV with Ruby's
csvlibrary (see the Expense Tracker project). - Refactor the methods into a
ContactBookclass (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.