Ruby do Zero: Projeto Final — Aplicação de Linha de Comando com Coleções, Blocos e Organização

Capítulo 21

Tempo estimado de leitura: 13 minutos

+ Exercício

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.90

Estrutura 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:

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

  • Execute ruby bin/finance e 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
end

Checkpoint manual:

  • ruby bin/finance deve 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
end

Checkpoint manual:

  • Abra irb e instancie uma transação para verificar métodos expense? e month_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
end

Crie 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
end

Agora 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
end

Checkpoint manual:

  • Crie um script rápido no irb ou em um arquivo temporário para chamar FinanceCLI::Parser.new("data/transactions.csv").read e ver se retorna um array de Transaction.
  • 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
end

Checkpoint manual:

  • Após ler o CSV, instancie Reports e imprima summary e totals_by_category para validar números.
  • Confirme que top_expenses retorna 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
end

Checkpoint manual:

  • Monte um headers e rows simples 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
end

Como o bloco ajuda aqui:

  • with_transactions carrega 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
end

Checkpoint manual:

  • ruby bin/finance summary deve imprimir uma tabela de resumo.
  • ruby bin/finance categories deve listar categorias com totais.
  • ruby bin/finance top --limit 3 deve listar 3 maiores despesas.
  • ruby bin/finance summary --file data/transactions.csv deve 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
end

O 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)
end

Faç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
end

Checkpoint manual:

  • ruby bin/finance categories --month 2026-01 deve considerar apenas o mês.
  • ruby bin/finance summary --category Alimentacao deve 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 FileNotFoundError com caminho.
  • CSV malformado: capturar CSV::MalformedCSVError e reempacotar como ParseError.
  • Conversões (data/valor): lançar ParseError com o valor problemático.
  • Argumentos inválidos: ao converter --limit, lançar ParseError com dica.

Checkpoint manual (testes de falha):

  • ruby bin/finance summary --file data/inexistente.csv deve retornar erro claro.
  • Coloque amount como abc em uma linha e rode summary para 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
end

Uso 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 strip e 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)
  end

Suporte 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
end

Esse 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.

Agora responda o exercício sobre o conteúdo:

Ao adicionar filtros opcionais na CLI (por mês e categoria), qual abordagem ajuda a manter o App enxuto e evita recarregar as transações mais de uma vez?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

Ao carregar uma vez, filtrar com bloco/pipeline e injetar o array filtrado nos métodos do App, você evita duplicar regras e impede que o App faça novo carregamento ao gerar cada relatório.

Capa do Ebook gratuito Ruby do Zero: Fundamentos, Coleções, Blocos e Organização de Código
100%

Ruby do Zero: Fundamentos, Coleções, Blocos e Organização de Código

Novo curso

21 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.