Automatización y scripts eficientes en Ruby para tareas reales

Capítulo 10

Tiempo estimado de lectura: 10 minutos

+ Ejercicio

Ruby como herramienta de automatización

Un script de automatización en Ruby es un programa pequeño y enfocado que ejecutas desde la terminal para resolver tareas repetitivas: procesar carpetas, renombrar archivos, leer logs y generar reportes. La clave para que sea “eficiente” no es solo que corra rápido, sino que sea reutilizable, seguro y con salidas claras para que puedas integrarlo en tu flujo de trabajo (o en un cron/CI).

Principios prácticos para scripts reutilizables

  • Interfaz consistente: acepta argumentos y opciones; si faltan, muestra ayuda y termina con un código de salida apropiado.
  • Salida clara: separa “resultado” (STDOUT) de “diagnóstico” (STDERR). Muestra resúmenes y contadores.
  • Modo seguro: evita sobrescribir archivos por defecto; ofrece --dry-run para simular cambios.
  • Casos borde: nombres con espacios, extensiones vacías, archivos duplicados, permisos insuficientes, rutas relativas/absolutas.
  • Idempotencia: si lo ejecutas dos veces, no debería romper nada ni duplicar trabajo.

Argumentos de línea de comandos con ARGV

Ruby expone los argumentos posicionales en el arreglo ARGV. Es ideal para scripts simples: ruby mi_script.rb archivo.log. Para opciones tipo --dry-run o --out reporte.csv, Ruby incluye OptionParser (biblioteca estándar), que te permite definir banderas y generar mensajes de ayuda.

Plantilla mínima de script con ayuda y opciones

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'optparse'

options = {
  dry_run: false,
  verbose: false
}

parser = OptionParser.new do |opts|
  opts.banner = "Uso: ruby script.rb [opciones] argumento1"

  opts.on("--dry-run", "Simula sin hacer cambios") { options[:dry_run] = true }
  opts.on("-v", "--verbose", "Muestra más detalle") { options[:verbose] = true }
  opts.on("-h", "--help", "Muestra esta ayuda") do
    puts opts
    exit 0
  end
end

begin
  parser.parse!(ARGV)
rescue OptionParser::ParseError => e
  warn "Error: #{e.message}"
  warn parser
  exit 2
end

if ARGV.empty?
  warn "Falta el argumento requerido."
  warn parser
  exit 2
end

arg1 = ARGV[0]
puts "Argumento: #{arg1}" if options[:verbose]

Buenas prácticas rápidas: usa exit 0 para éxito, exit 2 para uso incorrecto; imprime errores con warn (va a STDERR); y considera #!/usr/bin/env ruby para ejecutar como ./script.rb tras chmod +x.

Automatización típica 1: procesamiento de carpetas y renombrado

Procesar carpetas suele implicar: (1) listar archivos, (2) filtrar por criterio, (3) calcular un nuevo nombre/ruta, (4) aplicar cambios con seguridad. Un patrón útil es “planificar” primero (mostrar qué se haría) y luego ejecutar.

Checklist de seguridad para renombrar/mover

  • No sobrescribir por defecto: si el destino existe, decide una estrategia (sufijo incremental, saltar, o abortar).
  • Limitar el alcance: trabaja dentro de un directorio base; evita seguir enlaces simbólicos si no lo necesitas.
  • Validar rutas: normaliza con File.expand_path y verifica que el destino esté dentro del directorio base.
  • Dry-run: imprime operaciones antes de ejecutarlas.

Automatización típica 2: lectura de logs y generación de reportes

Un log es una fuente de datos “semi-estructurada”. Un script útil convierte líneas en métricas: conteos por nivel (INFO/WARN/ERROR), top de endpoints, latencias promedio, etc. Para mantenerlo robusto, evita asumir que todas las líneas cumplen el formato: cuenta “líneas inválidas” y sigue.

Continúa en nuestra aplicación.
  • Escuche el audio con la pantalla apagada.
  • Obtenga un certificado al finalizar.
  • ¡Más de 5000 cursos para que explores!
O continúa leyendo más abajo...
Download App

Descargar la aplicación

Formato de ejemplo de log

Usaremos un formato simple (una línea por evento):

2026-01-10T12:00:01Z INFO  user_id=42 action=login
2026-01-10T12:00:02Z WARN  user_id=42 action=retry
2026-01-10T12:00:03Z ERROR user_id=99 action=payment_failed

Ejercicio 1: script que analiza un archivo de registro y produce métricas

Objetivo

  • Entrada: ruta a un archivo .log.
  • Salida: métricas en consola y, opcionalmente, un CSV.
  • Métricas: total de líneas, líneas válidas/ inválidas, conteo por nivel, top 5 acciones.
  • Opciones: --out reporte.csv, --min-level WARN, --dry-run (solo para demostrar interfaz; aquí no modifica nada).

Paso a paso

  1. Parsear opciones y validar argumentos.
  2. Leer el archivo línea por línea (sin cargar todo en memoria).
  3. Parsear cada línea con una expresión regular tolerante.
  4. Acumular métricas en hashes.
  5. Imprimir resumen y, si se pidió, escribir CSV.

Código del script: log_metrics.rb

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'optparse'
require 'csv'

LEVEL_ORDER = { "DEBUG" => 0, "INFO" => 1, "WARN" => 2, "ERROR" => 3, "FATAL" => 4 }.freeze

options = {
  out: nil,
  min_level: "DEBUG",
  verbose: false
}

parser = OptionParser.new do |opts|
  opts.banner = "Uso: ruby log_metrics.rb [opciones] archivo.log"

  opts.on("--out PATH", "Escribe un CSV con métricas (opcional)") { |v| options[:out] = v }
  opts.on("--min-level LEVEL", "Nivel mínimo a contar (DEBUG, INFO, WARN, ERROR, FATAL)") { |v| options[:min_level] = v }
  opts.on("-v", "--verbose", "Muestra detalle") { options[:verbose] = true }
  opts.on("-h", "--help", "Ayuda") { puts opts; exit 0 }
end

begin
  parser.parse!(ARGV)
rescue OptionParser::ParseError => e
  warn "Error: #{e.message}"
  warn parser
  exit 2
end

path = ARGV[0]
if path.nil?
  warn "Debes indicar un archivo .log"
  warn parser
  exit 2
end

unless File.file?(path)
  warn "No existe el archivo: #{path}"
  exit 1
end

min_level = options[:min_level].upcase
unless LEVEL_ORDER.key?(min_level)
  warn "Nivel inválido: #{options[:min_level]}"
  exit 2
end

min_rank = LEVEL_ORDER[min_level]

# Métricas
counts_by_level = Hash.new(0)
actions = Hash.new(0)

total = 0
valid = 0
invalid = 0

# Ejemplo de línea: 2026-01-10T12:00:01Z INFO  user_id=42 action=login
line_re = /\A(?<ts>\S+)\s+(?<level>DEBUG|INFO|WARN|ERROR|FATAL)\s+(?<rest>.+)\z/
action_re = /\baction=(?<action>[A-Za-z0-9_\-]+)\b/

File.foreach(path, chomp: true) do |line|
  total += 1
  m = line_re.match(line)
  unless m
    invalid += 1
    next
  end

  level = m[:level]
  next if LEVEL_ORDER[level] < min_rank

  valid += 1
  counts_by_level[level] += 1

  if (am = action_re.match(m[:rest]))
    actions[am[:action]] += 1
  end
end

puts "Archivo: #{path}"
puts "Total líneas: #{total}"
puts "Válidas (nivel >= #{min_level}): #{valid}"
puts "Inválidas: #{invalid}"
puts "\nConteo por nivel:"
LEVEL_ORDER.keys.each do |lvl|
  next if LEVEL_ORDER[lvl] < min_rank
  puts "- #{lvl}: #{counts_by_level[lvl]}"
end

top_actions = actions.sort_by { |_, c| -c }.first(5)
puts "\nTop acciones:"
if top_actions.empty?
  puts "(sin acciones detectadas)"
else
  top_actions.each { |a, c| puts "- #{a}: #{c}" }
end

if options[:out]
  CSV.open(options[:out], "w") do |csv|
    csv << ["metric", "value"]
    csv << ["file", path]
    csv << ["total_lines", total]
    csv << ["valid_lines", valid]
    csv << ["invalid_lines", invalid]
    LEVEL_ORDER.keys.each do |lvl|
      next if LEVEL_ORDER[lvl] < min_rank
      csv << ["level_#{lvl.downcase}", counts_by_level[lvl]]
    end
    top_actions.each_with_index do |(a, c), idx|
      csv << ["top_action_#{idx + 1}_name", a]
      csv << ["top_action_#{idx + 1}_count", c]
    end
  end
  warn "CSV escrito en: #{options[:out]}" if options[:verbose]
end

Pruebas rápidas

# Solo consola
ruby log_metrics.rb app.log

# Filtrar desde WARN y exportar CSV
ruby log_metrics.rb --min-level WARN --out reporte.csv app.log

Casos borde a considerar (y cómo los maneja)

  • Líneas mal formateadas: se cuentan como inválidas y no rompen el script.
  • Nivel desconocido: se rechaza en la opción --min-level.
  • Archivo inexistente: termina con error y mensaje claro.
  • Acción ausente: no suma al ranking, pero la línea sigue contando.

Ejercicio 2: organizar archivos por extensión en subcarpetas

Objetivo

  • Entrada: un directorio.
  • Acción: mover archivos a subcarpetas según extensión: jpg, pdf, txt, etc.
  • Opciones: --dry-run, --recursive, --unknown NAME (carpeta para archivos sin extensión), --conflict (skip|rename|abort).
  • Seguridad: no salir del directorio base; no seguir symlinks (por defecto); no sobrescribir sin estrategia.

Paso a paso

  1. Resolver directorio base a ruta absoluta y validar que exista.
  2. Recorrer archivos (solo archivos regulares) y decidir destino por extensión.
  3. Crear subcarpetas si no existen.
  4. Resolver conflictos si el destino ya existe.
  5. Ejecutar o simular según --dry-run.

Código del script: organize_by_ext.rb

#!/usr/bin/env ruby
# frozen_string_literal: true

require 'optparse'
require 'fileutils'

options = {
  dry_run: false,
  recursive: false,
  unknown: "no_extension",
  conflict: "rename", # skip|rename|abort
  verbose: false
}

parser = OptionParser.new do |opts|
  opts.banner = "Uso: ruby organize_by_ext.rb [opciones] directorio"

  opts.on("--dry-run", "Simula movimientos") { options[:dry_run] = true }
  opts.on("--recursive", "Incluye subcarpetas") { options[:recursive] = true }
  opts.on("--unknown NAME", "Carpeta para archivos sin extensión") { |v| options[:unknown] = v }
  opts.on("--conflict MODE", "Conflictos: skip, rename, abort") { |v| options[:conflict] = v }
  opts.on("-v", "--verbose", "Detalle") { options[:verbose] = true }
  opts.on("-h", "--help", "Ayuda") { puts opts; exit 0 }
end

begin
  parser.parse!(ARGV)
rescue OptionParser::ParseError => e
  warn "Error: #{e.message}"
  warn parser
  exit 2
end

dir = ARGV[0]
if dir.nil?
  warn "Debes indicar un directorio"
  warn parser
  exit 2
end

base = File.expand_path(dir)
unless Dir.exist?(base)
  warn "No existe el directorio: #{base}"
  exit 1
end

unless %w[skip rename abort].include?(options[:conflict])
  warn "--conflict inválido: #{options[:conflict]}"
  exit 2
end

# Evita que un destino se salga del directorio base
# (protección básica ante rutas raras)
def within_base?(base, path)
  b = File.expand_path(base)
  p = File.expand_path(path)
  p.start_with?(b + File::SEPARATOR)
end

# Genera un destino no existente: archivo(1).ext, archivo(2).ext...
def next_available_path(path)
  return path unless File.exist?(path)

  dir = File.dirname(path)
  ext = File.extname(path)
  name = File.basename(path, ext)

  i = 1
  loop do
    candidate = File.join(dir, "#{name}(#{i})#{ext}")
    return candidate unless File.exist?(candidate)
    i += 1
  end
end

pattern = options[:recursive] ? File.join(base, "**", "*") : File.join(base, "*")

moved = 0
skipped = 0
errors = 0

Dir.glob(pattern, File::FNM_DOTMATCH).each do |src|
  next if [".", ".."].include?(File.basename(src))

  # No tocar directorios
  next if File.directory?(src)

  # No seguir symlinks por seguridad básica
  if File.symlink?(src)
    skipped += 1
    warn "Skip symlink: #{src}" if options[:verbose]
    next
  end

  # Solo archivos regulares
  unless File.file?(src)
    skipped += 1
    next
  end

  ext = File.extname(src).downcase
  ext = ext.sub(/\A\./, "")
  folder = ext.empty? ? options[:unknown] : ext

  dest_dir = File.join(base, folder)
  dest = File.join(dest_dir, File.basename(src))

  unless within_base?(base, dest_dir) && within_base?(base, dest)
    errors += 1
    warn "Destino fuera del directorio base, abortando: #{dest}"
    exit 1
  end

  begin
    if File.exist?(dest)
      case options[:conflict]
      when "skip"
        skipped += 1
        warn "Existe, skip: #{dest}" if options[:verbose]
        next
      when "abort"
        warn "Existe destino, abortando: #{dest}"
        exit 1
      when "rename"
        dest = next_available_path(dest)
      end
    end

    if options[:dry_run]
      puts "MOVE #{src} -> #{dest}"
    else
      FileUtils.mkdir_p(dest_dir)
      FileUtils.mv(src, dest)
      puts "Moved: #{src} -> #{dest}" if options[:verbose]
    end

    moved += 1
  rescue SystemCallError => e
    errors += 1
    warn "Error moviendo #{src}: #{e.class} #{e.message}"
  end
end

puts "Base: #{base}"
puts "Movidos: #{moved}"
puts "Saltados: #{skipped}"
puts "Errores: #{errors}"

Pruebas rápidas

# Simular sin modificar nada
ruby organize_by_ext.rb --dry-run Descargas

# Organizar incluyendo subcarpetas y renombrar en conflictos
ruby organize_by_ext.rb --recursive --conflict rename Descargas

Casos borde y decisiones de diseño

CasoRiesgoEstrategia del script
Archivos sin extensiónQuedan “sueltos”Van a carpeta configurable --unknown
Extensiones en mayúsculas (.JPG)Carpetas duplicadasNormaliza a minúsculas
Conflicto de nombreSobrescrituraskip, abort o rename con sufijo
SymlinksEscapar del directorio baseSe omiten por defecto
Archivos ocultosProcesamiento inesperadoSe incluyen por glob; puedes filtrarlos si lo prefieres

Patrones útiles para salidas claras y automatización en pipelines

Separar salida de datos vs. diagnósticos

  • Usa puts para resultados consumibles.
  • Usa warn para mensajes de error/estado (va a STDERR).

Códigos de salida recomendados

  • 0: éxito.
  • 1: fallo operativo (archivo no existe, permisos, etc.).
  • 2: uso incorrecto (opciones inválidas, faltan argumentos).

Modo dry-run como estándar

En scripts que mueven/renombran, --dry-run reduce riesgos y facilita revisar el “plan” antes de ejecutar cambios reales.

Ahora responde el ejercicio sobre el contenido:

En un script de Ruby pensado para integrarse en cron o CI, ¿qué práctica ayuda más a lograr salidas claras y fácil automatización?

¡Tienes razón! Felicitaciones, ahora pasa a la página siguiente.

¡Tú error! Inténtalo de nuevo.

Separar resultados (STDOUT) de diagnósticos (STDERR) y usar códigos de salida (0 éxito, 1 fallo operativo, 2 uso incorrecto) facilita integrar el script en pipelines y detectar fallos de forma confiable.

Portada de libro electrónico gratuitaRuby desde Cero para principiantes: Programación Moderna, Clara y Eficiente
100%

Ruby desde Cero para principiantes: Programación Moderna, Clara y Eficiente

Nuevo curso

10 páginas

Descarga la aplicación para obtener una certificación gratuita y escuchar cursos en segundo plano, incluso con la pantalla apagada.