Visão do projeto e requisitos funcionais
Neste projeto final, você vai construir uma aplicação de linha de comando (CLI) que lê dados de um arquivo, transforma esses dados usando arrays/hashes e Enumerable, aplica blocos em pontos-chave (para customização e reuso) e imprime uma saída formatada no terminal. O objetivo é consolidar a base do curso em um código organizado e extensível, com evolução incremental, checkpoints de validação manual, tratamento de exceções e refatorações para um estilo idiomático.
O que a aplicação faz
- Lê um arquivo CSV simples de transações (data, categoria, descrição, valor).
- Valida e normaliza os dados (ex.: valor numérico, data no formato esperado).
- Gera relatórios no terminal: totais por categoria, top N despesas, resumo mensal.
- Permite filtros via argumentos (ex.: mês, categoria, tipo receita/despesa).
- Organiza o código em camadas: leitura/parsing, domínio (transação), agregação (relatórios) e apresentação (formatador).
Formato do arquivo de entrada
Crie um arquivo data/transactions.csv com cabeçalho e linhas como abaixo (valores negativos representam despesas):
date,category,description,amount
2026-01-05,Alimentacao,Supermercado,-235.90
2026-01-10,Salario,Empresa,4500.00
2026-01-12,Transporte,Combustivel,-180.00
2026-01-15,Alimentacao,Restaurante,-72.50
2026-01-20,Lazer,Cinema,-45.00
2026-01-22,Saude,Farmacia,-38.90Estrutura de pastas e arquivos
Uma estrutura simples, mas escalável, para uma CLI Ruby:
finance_cli/
bin/
finance
lib/
finance_cli/
app.rb
cli.rb
transaction.rb
parser.rb
reports.rb
formatter.rb
errors.rb
utils.rb
data/
transactions.csv
Gemfile (opcional)
README.md (opcional)Responsabilidades (separação por arquivos)
bin/finance: ponto de entrada executável (chama a CLI).lib/finance_cli/cli.rb: interpreta argumentos e aciona comandos.lib/finance_cli/app.rb: orquestra leitura, filtros, relatórios e saída.lib/finance_cli/parser.rb: lê e converte linhas do CSV em objetos/estruturas.lib/finance_cli/transaction.rb: representa uma transação (dados e validações).lib/finance_cli/reports.rb: agrega e calcula métricas (Enumerable).lib/finance_cli/formatter.rb: formata tabelas e valores para o terminal.lib/finance_cli/errors.rb: erros específicos do domínio.lib/finance_cli/utils.rb: utilitários pequenos (ex.: parse de data/decimal).
Passo a passo incremental (com checkpoints)
Etapa 1 — Executável mínimo e carregamento do app
Crie bin/finance e torne-o executável. Ele deve apenas chamar a CLI.
#!/usr/bin/env ruby
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "finance_cli/cli"
FinanceCLI::CLI.run(ARGV)Checkpoint manual:
- Ouça o áudio com a tela desligada
- Ganhe Certificado após a conclusão
- + de 5000 cursos para você explorar!
Baixar o aplicativo
- Execute
ruby bin/financee confirme que não há erro de require (mesmo que ainda não faça nada).
Agora crie o namespace base em lib/finance_cli/cli.rb:
module FinanceCLI
class CLI
def self.run(argv)
puts "Uso: finance <comando> [opcoes]"
puts "Comandos: summary, categories, top"
end
end
endCheckpoint manual:
ruby bin/financedeve imprimir o texto de uso.
Etapa 2 — Modelo de domínio: Transaction
Em vez de trabalhar com hashes soltos desde o início, crie um objeto simples para carregar dados já normalizados. Isso facilita validação e refatorações.
module FinanceCLI
class Transaction
attr_reader :date, :category, :description, :amount
def initialize(date:, category:, description:, amount:)
@date = date
@category = category
@description = description
@amount = amount
end
def expense?
amount < 0
end
def income?
amount > 0
end
def month_key
date.strftime("%Y-%m")
end
end
endCheckpoint manual:
- Abra
irbe instancie uma transação para verificar métodosexpense?emonth_key.
Etapa 3 — Parser: leitura do CSV e transformação em coleções
O parser é onde você transforma linhas do arquivo em uma coleção (array) de transações. Aqui você aplica validações e converte tipos.
Crie lib/finance_cli/errors.rb:
module FinanceCLI
class ParseError < StandardError; end
class FileNotFoundError < StandardError; end
endCrie lib/finance_cli/utils.rb para centralizar conversões:
require "date"
module FinanceCLI
module Utils
def self.parse_date(value)
Date.strptime(value.strip, "%Y-%m-%d")
rescue ArgumentError
raise ParseError, "Data invalida: #{value.inspect} (esperado YYYY-MM-DD)"
end
def self.parse_amount(value)
str = value.to_s.strip
raise ParseError, "Valor vazio" if str.empty?
Float(str)
rescue ArgumentError
raise ParseError, "Valor invalido: #{value.inspect}"
end
end
endAgora o parser.rb:
require "csv"
require "finance_cli/transaction"
require "finance_cli/errors"
require "finance_cli/utils"
module FinanceCLI
class Parser
def initialize(path)
@path = path
end
def read
raise FileNotFoundError, "Arquivo nao encontrado: #{@path}" unless File.exist?(@path)
transactions = []
CSV.foreach(@path, headers: true) do |row|
transactions << build_transaction(row)
end
transactions
rescue CSV::MalformedCSVError => e
raise ParseError, "CSV malformado: #{e.message}"
end
private
def build_transaction(row)
date = Utils.parse_date(row["date"])
category = row["category"].to_s.strip
description = row["description"].to_s.strip
amount = Utils.parse_amount(row["amount"])
if category.empty?
raise ParseError, "Categoria vazia na linha: #{row.inspect}"
end
Transaction.new(date: date, category: category, description: description, amount: amount)
end
end
endCheckpoint manual:
- Crie um script rápido no
irbou em um arquivo temporário para chamarFinanceCLI::Parser.new("data/transactions.csv").reade ver se retorna um array deTransaction. - Teste um erro proposital (data inválida) e confirme que a exceção é clara.
Etapa 4 — Reports: agregações com Enumerable
Agora que você tem um array de transações, use group_by, map, sort_by e sum para gerar relatórios. A ideia é manter o cálculo separado da apresentação.
module FinanceCLI
class Reports
def initialize(transactions)
@transactions = transactions
end
def summary
total = @transactions.sum(&:amount)
incomes = @transactions.select(&:income?).sum(&:amount)
expenses = @transactions.select(&:expense?).sum(&:amount)
{
count: @transactions.size,
total: total,
incomes: incomes,
expenses: expenses
}
end
def totals_by_category
grouped = @transactions.group_by(&:category)
grouped.map do |category, items|
{ category: category, total: items.sum(&:amount), count: items.size }
end.sort_by { |row| row[:total] }
end
def top_expenses(limit: 5)
@transactions
.select(&:expense?)
.sort_by { |t| t.amount }
.first(limit)
end
def totals_by_month
@transactions
.group_by(&:month_key)
.map { |month, items| { month: month, total: items.sum(&:amount) } }
.sort_by { |row| row[:month] }
end
end
endCheckpoint manual:
- Após ler o CSV, instancie
Reportse imprimasummaryetotals_by_categorypara validar números. - Confirme que
top_expensesretorna as despesas mais negativas primeiro.
Etapa 5 — Formatter: saída formatada no terminal
Para manter a CLI limpa, crie um formatador simples de tabela. Ele recebe dados (arrays/hashes) e devolve string pronta.
module FinanceCLI
class Formatter
def money(value)
sign = value < 0 ? "-" : ""
formatted = format("%.2f", value.abs)
"#{sign}R$ #{formatted}"
end
def table(headers, rows)
widths = headers.map.with_index do |h, i|
[h.to_s.size, rows.map { |r| r[i].to_s.size }.max.to_i].max
end
line = widths.map { |w| "-" * (w + 2) }.join("+")
out = []
out << line
out << headers.map.with_index { |h, i| " #{h.to_s.ljust(widths[i])} " }.join("|")
out << line
rows.each do |row|
out << row.map.with_index { |cell, i| " #{cell.to_s.ljust(widths[i])} " }.join("|")
end
out << line
out.join("\n")
end
end
endCheckpoint manual:
- Monte um
headerserowssimples e verifique alinhamento.
Etapa 6 — App: orquestração e filtros (com blocos em pontos-chave)
O App conecta parser, filtros e relatórios. Um ponto importante do projeto é usar blocos para tornar a aplicação extensível: por exemplo, permitir que um filtro seja passado como bloco para selecionar transações.
require "finance_cli/parser"
require "finance_cli/reports"
require "finance_cli/formatter"
module FinanceCLI
class App
DEFAULT_PATH = File.expand_path("../../../data/transactions.csv", __dir__)
def initialize(path: DEFAULT_PATH)
@path = path
@formatter = Formatter.new
end
def load_transactions
Parser.new(@path).read
end
def with_transactions
tx = load_transactions
tx = yield(tx) if block_given?
tx
end
def run_summary
tx = with_transactions
data = Reports.new(tx).summary
rows = [
["Transacoes", data[:count]],
["Receitas", @formatter.money(data[:incomes])],
["Despesas", @formatter.money(data[:expenses])],
["Total", @formatter.money(data[:total])]
]
@formatter.table(["Item", "Valor"], rows)
end
def run_categories
tx = with_transactions
rows = Reports.new(tx).totals_by_category.map do |r|
[r[:category], r[:count], @formatter.money(r[:total])]
end
@formatter.table(["Categoria", "Qtd", "Total"], rows)
end
def run_top(limit: 5)
tx = with_transactions
items = Reports.new(tx).top_expenses(limit: limit)
rows = items.map do |t|
[t.date.to_s, t.category, t.description, @formatter.money(t.amount)]
end
@formatter.table(["Data", "Categoria", "Descricao", "Valor"], rows)
end
end
endComo o bloco ajuda aqui:
with_transactionscarrega e permite aplicar um filtro externo sem acoplar o App a todas as regras.- A CLI pode passar um bloco para filtrar por mês/categoria/tipo, mantendo o App enxuto.
Etapa 7 — CLI: comandos, opções e validação de argumentos
Implemente uma CLI simples baseada em ARGV. O foco é manter parsing de argumentos legível e com mensagens úteis.
require "finance_cli/app"
require "finance_cli/errors"
module FinanceCLI
class CLI
def self.run(argv)
command = argv[0]
options = argv[1..] || []
app = App.new(path: extract_path(options) || App::DEFAULT_PATH)
case command
when "summary"
puts safe_run { app.run_summary }
when "categories"
puts safe_run { app.run_categories }
when "top"
limit = extract_int(options, "--limit") || 5
puts safe_run { app.run_top(limit: limit) }
else
puts usage
end
end
def self.safe_run
yield
rescue FileNotFoundError, ParseError => e
"Erro: #{e.message}"
end
def self.extract_path(options)
extract_value(options, "--file")
end
def self.extract_int(options, key)
value = extract_value(options, key)
return nil if value.nil?
Integer(value)
rescue ArgumentError
raise ParseError, "Valor invalido para #{key}: #{value.inspect}"
end
def self.extract_value(options, key)
idx = options.index(key)
return nil if idx.nil?
options[idx + 1]
end
def self.usage
[
"Uso:",
" finance summary [--file caminho]",
" finance categories [--file caminho]",
" finance top [--limit N] [--file caminho]"
].join("\n")
end
end
endCheckpoint manual:
ruby bin/finance summarydeve imprimir uma tabela de resumo.ruby bin/finance categoriesdeve listar categorias com totais.ruby bin/finance top --limit 3deve listar 3 maiores despesas.ruby bin/finance summary --file data/transactions.csvdeve funcionar com caminho explícito.
Evolução: adicionando filtros com blocos (sem bagunçar o App)
Agora vamos evoluir a CLI para aceitar filtros opcionais e aplicá-los via bloco em with_transactions. Isso evita duplicação de lógica entre comandos.
Filtro por mês e categoria
Adicione suporte a --month YYYY-MM e --category Nome. Em vez de colocar isso dentro de cada método do App, a CLI passa um bloco que filtra o array.
# Dentro de CLI.run, antes do case:
month = extract_value(options, "--month")
category = extract_value(options, "--category")
filter = lambda do |tx|
tx = tx.select { |t| t.month_key == month } if month
tx = tx.select { |t| t.category == category } if category
tx
end
# Exemplo para summary:
puts safe_run do
app.with_transactions(&filter) # carrega e filtra
app.run_summary
endO trecho acima ainda tem um problema: run_summary chama with_transactions internamente. Para evitar carregar duas vezes, refatore o App para aceitar transações já filtradas.
Refatoração: App recebe transações (injeção) para evitar reload
Altere os métodos do App para aceitarem um array opcional. Se não for passado, ele carrega normalmente.
# Em app.rb
def run_summary(transactions: nil)
tx = transactions || with_transactions
data = Reports.new(tx).summary
rows = [
["Transacoes", data[:count]],
["Receitas", @formatter.money(data[:incomes])],
["Despesas", @formatter.money(data[:expenses])],
["Total", @formatter.money(data[:total])]
]
@formatter.table(["Item", "Valor"], rows)
endFaça o mesmo para run_categories e run_top. Agora a CLI pode carregar uma vez, filtrar e passar adiante:
# Em CLI.run
transactions = safe_run { app.with_transactions(&filter) }
case command
when "summary"
puts safe_run { app.run_summary(transactions: transactions) }
when "categories"
puts safe_run { app.run_categories(transactions: transactions) }
when "top"
limit = extract_int(options, "--limit") || 5
puts safe_run { app.run_top(limit: limit, transactions: transactions) }
else
puts usage
endCheckpoint manual:
ruby bin/finance categories --month 2026-01deve considerar apenas o mês.ruby bin/finance summary --category Alimentacaodeve resumir apenas a categoria.
Tratamento de exceções: pontos críticos e mensagens úteis
Em uma CLI, erros comuns devem ser tratados com mensagens diretas. Alguns pontos críticos:
- Arquivo inexistente: lançar
FileNotFoundErrorcom caminho. - CSV malformado: capturar
CSV::MalformedCSVErrore reempacotar comoParseError. - Conversões (data/valor): lançar
ParseErrorcom o valor problemático. - Argumentos inválidos: ao converter
--limit, lançarParseErrorcom dica.
Checkpoint manual (testes de falha):
ruby bin/finance summary --file data/inexistente.csvdeve retornar erro claro.- Coloque
amountcomoabcem uma linha e rodesummarypara ver a mensagem.
Refatorações para código idiomático e extensível
1) Evitar repetição de formatação de dinheiro
Se você perceber que está chamando money em vários lugares, mantenha isso no Formatter e nunca formate valores monetários fora dele. Isso facilita trocar o padrão (ex.: separador decimal) depois.
2) Padronizar retorno de relatórios
Nos relatórios, prefira retornar estruturas simples (hashes/arrays) e deixe a apresentação para o formatador. Exemplo: totals_by_category retorna array de hashes com :category, :count, :total.
3) Introduzir um pipeline com bloco para transformações
Se você quiser permitir múltiplas transformações encadeadas, crie um método que aplica uma sequência de blocos (útil quando a CLI cresce):
module FinanceCLI
class App
def transform(transactions, *steps)
steps.reduce(transactions) { |acc, step| step.call(acc) }
end
end
endUso na CLI:
steps = []
steps << ->(tx) { tx.select { |t| t.month_key == month } } if month
steps << ->(tx) { tx.select { |t| t.category == category } } if category
transactions = safe_run { app.transform(app.load_transactions, *steps) }4) Checkpoints de validação como hábito
Ao evoluir a CLI, mantenha pequenos checkpoints:
- Após cada refatoração, rode pelo menos:
summary,categories,top. - Teste um arquivo com 1 linha (caso mínimo) e um arquivo vazio (apenas cabeçalho).
- Teste categorias com espaços e acentos para garantir que
stripe comparação funcionem como esperado.
Extensões sugeridas (para continuar evoluindo)
Novo comando: monthly
Adicione um comando monthly que imprime totais por mês usando Reports#totals_by_month:
# Em CLI.run
when "monthly"
puts safe_run do
tx = app.load_transactions
rows = Reports.new(tx).totals_by_month.map { |r| [r[:month], Formatter.new.money(r[:total])] }
Formatter.new.table(["Mes", "Total"], rows)
endSuporte a múltiplos arquivos
Permita --file repetido e concatene transações. Isso exercita coleções e composição:
paths = []
while (p = extract_value(options, "--file"))
paths << p
# (em uma CLI real, você removeria os pares já consumidos)
break
end
all = paths.flat_map { |path| Parser.new(path).read }Normalização de categoria (bloco de mapeamento)
Se o CSV vier com categorias inconsistentes, aplique um bloco de normalização:
normalize_category = ->(tx) do
tx.map do |t|
cat = t.category.downcase
cat = "alimentacao" if ["alimento", "alimentacao", "comida"].include?(cat)
FinanceCLI::Transaction.new(date: t.date, category: cat.capitalize, description: t.description, amount: t.amount)
end
endEsse tipo de transformação mostra bem o uso de blocos e a força de map para gerar uma nova coleção sem mutação.