En el capítulo anterior construiste crustaceo-stats, un clon mejorado de wc. Leías archivos enteros en memoria, contabas líneas y palabras, y mostrabas resultados con colores. Todo eso está bien, pero es solo el aperitivo. La cuestión, es que como has podido ver tu mismo, todo esto no es tan difícil, como te podrías haber imaginado. Mas o menos, es muy similar a lo que harías en Python. Te traes los módulos y los vas utilizando según necesitas. Similar, aunque por supuesto con una sintaxis diferentes. Pero, como te digo, nada excesivamente complicado. Pero tenemos que seguir avanzando. El día a día de cualquier linuxero está lleno de tareas que implican procesar archivos línea a línea, filtrar logs, extraer campos de un CSV, buscar patrones, transformar columnas, reemplazar texto. En Bash usas awk, sed, grep. En Rust… haces lo mismo, pero con tipos, sin sorpresas, y a velocidad de Turbo Pascal (sí, esta referencia la pillan los que peinamos canas).
Procesando archivos, ¿qué vamos a ver?
El objetivo de este capítulo es que aprendas a realizar una serie de operaciones que, si eres como yo, haces a diario en tu terminal. Operaciones como,
- Leer archivos línea por línea sin cargarlos enteros en memoria
- Usar expresiones regulares para buscar y extraer patrones
- Manipular strings como un profesional (split, trim, replace, capturas)
- Filtrar y transformar líneas sobre la marcha
- Construir
crustaceo-logparse, un analizador de logs que te hará olvidargrep | awk | sort | uniq -c
Todo el código que veas aquí compila y funciona. Nada de fragmentos teóricos sin contexto. Me gusta ir directo al grano.
¿Y cómo lees un archivo de 2GB sin morir en el intento?
En el capítulo anterior vimos como leer archivos enteros con read_to_string(). Para archivos pequeños va bien, pero te aseguro que si tienes un log de 2GB, cargarlo entero en RAM es una temeridad. Lo sé porque lo he intentado. Y no termina bien.
La alternativa profesional es BufReader de std::io::BufReader. Básicamente, lo que hace es leer el archivo en bloques (por defecto 8KB) y te va dando líneas una a una. Así puedes procesar archivos de cualquier tamaño sin agotar la memoria. Es como tener un grifo que va soltando agua, en lugar de intentar tragar el río entero de un sorbo.
Crea el proyecto para este capítulo:
cargo new crustaceo-logparse
cd crustaceo-logparse
Lo primero, edita Cargo.toml con las dependencias que vamos a necesitar:
[package]
name = "crustaceo-logparse"
version = "0.1.0"
edition = "2024"
[dependencies]
regex = "1"
anyhow = "1"
colored = "2"
Vamos con ellas,
regex— expresiones regulares. La crate de referencia en Rust, compila los patrones a código máquina. No hay nada más rápido.anyhow— manejo de errores simplificado. Nos permite devolver cualquier error con?sin tener que especificar el tipo exacto. Un must en herramientas de línea de comandos.colored— ya la conoces del capítulo anterior.
Ahora escribe src/main.rs con un ejemplo básico de lectura línea a línea:
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Uso: {} <archivo>", args[0]);
std::process::exit(1);
}
let ruta = PathBuf::from(&args[1]);
let archivo = File::open(&ruta)?;
let lector = BufReader::new(archivo);
let mut num_linea = 0u64;
for linea in lector.lines() {
num_linea += 1;
let linea = linea?; // desempaqueta el Result de cada línea
println!("{:>6}: {}", num_linea, linea);
}
eprintln!("Total líneas: {}", num_linea);
Ok(())
}
Compila y pruébalo con cualquier archivo de texto, por ejemplo el propio Cargo.toml:
cargo build
./target/debug/crustaceo-logparse Cargo.toml
Deberías ver cada línea numerada. Si el archivo tiene 100.000 líneas, te aseguro que el programa apenas usa memoria. ¿La razón? BufReader va soltando líneas según las lee.
Nota al margen: Cuando empecé con Rust, me costó entender por qué no se podía leer un archivo directamente como en Python con
open().readlines(). La respuesta es simple: Rust no asume que tu archivo cabe en memoria. Y hace bien. En producción, los archivos no tienen tamaño. He visto logs de 50GB en servidores. ConBufReaderte da igual.
¿Y cómo funciona BufReader por dentro?
BufReader::new(archivo) envuelve el File en un buffer. Cuando haces lector.lines(), obtienes un iterador que devuelve io::Result<String>. Cada llamada al iterador hace tres cosas:
- Lee del buffer interno (que a su vez lee del disco en bloques)
- Busca el siguiente
\n - Devuelve la línea sin el salto de línea
Esto es importante: las líneas que obtienes no incluyen el \n. Si necesitas conservarlo, tienes que añadirlo manualmente. No es una limitación, es una decisión de diseño: el 99% de las veces no lo necesitas, y si lo hicieras, lo añades tú.
El patrón for linea in lector.lines() con let linea = linea? es el idiomático en Rust para procesar archivos. El primer ? desempaqueta el Result de cada línea, y el ? de File::open y el anyhow::Result<()> de main() permiten propagar cualquier error.
Expresiones regulares con regex, ¿por qué no vienen incluidas?
Las regex en Rust no están en la biblioteca estándar. Esto sorprende al principio, pero tiene su razón de ser: la biblioteca estándar de Rust es pequeña por diseño. Prefieren mantenerla ligera y que las cosas especializadas vivan en crates externos. Necesitas la crate regex, que ya añadimos al Cargo.toml. Es rápida, segura y compila los patrones en tiempo de compilación (con la macro regex!) o en runtime (con Regex::new).
Manos a la obra con regex
use regex::Regex;
fn main() -> anyhow::Result<()> {
// Compilar la regex en runtime
let re = Regex::new(r"error|warning|info")?;
let texto = "2024-06-24 10:30:00 error: conexion fallida";
if re.is_match(texto) {
println!("¡Coincidencia encontrada!");
}
// Encontrar la primera coincidencia
if let Some(m) = re.find(texto) {
println!("Encontrado: '{}' en posiciones {}..{}", m.as_str(), m.start(), m.end());
}
Ok(())
}
Ejecuta este código, puedes sustituir temporalmente el main.rs o crear un binario de ejemplo aparte. La salida debería ser:
¡Coincidencia encontrada!
Encontrado: 'error' en posiciones 21..26
Fíjate en la r antes de las comillas: r"error|warning|info". La r indica raw string literal, lo que significa que las barras invertidas se interpretan literalmente, sin escape. En regex es casi obligatorio usarlo porque los patrones suelen contener \d, \s, \w, etc. Si no usas r"...", tendrías que escapar cada barra: "\\d" en lugar de r"\d". Un coñazo.
Capturas con grupos, o cómo extraer lo que te interesa
Aquí es donde las regex se vuelven realmente útiles. Los grupos de captura (...) te permiten extraer partes concretas de una línea.
use regex::Regex;
fn main() -> anyhow::Result<()> {
// Patron para logs tipicos: timestamp - nivel - mensaje
let re = Regex::new(
r"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\w+):\s+(.+)$"
)?;
let linea = "2024-06-24 10:30:00 ERROR: conexion fallida con el servidor";
if let Some(caps) = re.captures(linea) {
println!("Timestamp: {}", &caps[1]);
println!("Nivel: {}", &caps[2]);
println!("Mensaje: {}", &caps[3]);
}
Ok(())
}
Las capturas se acceden por índice con caps[1], caps[2], etc. El índice 0 es siempre la coincidencia completa.
Desglose del patrón, por si te pierdes:
| Parte | Significado |
|---|---|
^ | Inicio de línea |
(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) | Captura 1: timestamp ISO |
\s+ | Espacio(s) separador(es) |
(\w+) | Captura 2: nivel (ERROR, WARNING, etc.) |
:\s+ | Dos puntos y espacio |
(.+)$ | Captura 3: resto de la línea hasta el final |
Capturas con nombre, para no volverte loco con los números
Para no tener que acordarte de los números, puedes usar capturas con nombre. Te cambio la vida:
let re = Regex::new(
r"^(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(?P<nivel>\w+):\s+(?P<msg>.+)$"
)?;
// ...
if let Some(caps) = re.captures(linea) {
println!("Timestamp: {}", &caps["ts"]);
println!("Nivel: {}", &caps["nivel"]);
println!("Mensaje: {}", &caps["msg"]);
}
La sintaxis (?P<nombre>...) asigna un nombre al grupo. Luego accedes con caps["nombre"]. Mucho más legible cuando tienes varios grupos, créeme.
Manipulación de strings: split, trim, replace, capturas
Antes de que te lances a usar regex para todo, recuerda que los métodos nativos de &str y String cubren el 80% de los casos. Y son más rápidos. Siempre empiezo por aquí, y solo tiro de regex cuando lo nativo no llega.
split y split_whitespace, tus nuevos mejores amigos
fn main() {
let linea = "usuario:lorenzo:1000:1000::/home/lorenzo:/bin/bash";
// Dividir por ':'
let campos: Vec<&str> = linea.split(':').collect();
println!("Usuario: {}", campos[0]);
println!("UID: {}", campos[2]);
println!("Shell: {}", campos[6]);
// split_whitespace para separar por cualquier espacio
let palabras: Vec<&str> = "error fatal en modulo 42".split_whitespace().collect();
println!("Palabras: {:?}", palabras);
// ["error", "fatal", "en", "modulo", "42"]
}
split_whitespace() es especialmente útil porque ignora espacios múltiples y tabs, algo que split(' ') no hace. Me ha sacado de más de un apuro.
trim y sus variantes, límpiate de espacios
fn main() {
let sucio = " \t línea con espacios \n ";
println!("'{}'", sucio.trim()); // elimina ambos extremos
println!("'{}'", sucio.trim_start()); // solo inicio
println!("'{}'", sucio.trim_end()); // solo final
// trim_matches para caracteres concretos
let entrecomillado = "\"hola mundo\"";
println!("'{}'", entrecomillado.trim_matches('"'));
// → hola mundo sin comillas
}
replace y sus amigos, buscando y reemplazando
fn main() {
let texto = "El usuario usuario lorenzo ha iniciado sesion";
// Reemplazar todas las ocurrencias
let corregido = texto.replace("usuario", "usuaria");
println!("{}", corregido);
// El usuaria usuaria lorenzo ha iniciado sesion
// Reemplazar solo la primera
let primera = texto.replacen("usuario", "usuaria", 1);
println!("{}", primera);
// El usuaria usuario lorenzo ha iniciado sesion
// Reemplazar con regex (mas potente)
let re = regex::Regex::new(r"\busuario\b").unwrap();
let resultado = re.replace_all(texto, "usuaria");
println!("{}", resultado);
// El usuaria usuaria lorenzo ha iniciado sesion
// La diferencia: \b marca limite de palabra
}
Extraer substrings con capturas, el último recurso
Cuando necesitas extraer partes concretas de una línea, las capturas de regex son tu mejor aliado:
use regex::Regex;
fn main() -> anyhow::Result<()> {
let re = Regex::new(r"(\d{1,3}\.){3}\d{1,3}")?; // IP v4 basica
let linea = "Failed password for root from 192.168.1.100 port 22 ssh2";
if let Some(ip) = re.find(linea) {
println!("IP encontrada: {}", ip.as_str());
}
// Extraer multiples IPs
let ips: Vec<&str> = re.find_iter(linea).map(|m| m.as_str()).collect();
println!("IPs encontradas: {:?}", ips);
Ok(())
}
Filtrado y transformación, o cómo ser tu propio grep/sed
Ya tienes las piezas. Ahora toca combinarlas para hacer lo mismo que haces con grep y sed: filtrar líneas que cumplen una condición y transformar las que pasan el filtro.
Filtrar líneas, como grep pero en Rust
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("Uso: {} <patron> <archivo>", args[0]);
std::process::exit(1);
}
let patron = Regex::new(&args[1])?;
let ruta = PathBuf::from(&args[2]);
let archivo = File::open(&ruta)?;
let lector = BufReader::new(archivo);
for linea in lector.lines() {
let linea = linea?;
if patron.is_match(&linea) {
println!("{}", linea);
}
}
Ok(())
}
Esto ya es un grep básico. Pero podemos mejorarlo: añadir número de línea, resaltar la coincidencia, mostrar contexto…
Filtrar y transformar, como sed + awk en una batidora
use regex::Regex;
use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
eprintln!("Uso: {} <archivo>", args[0]);
std::process::exit(1);
}
let archivo = File::open(&args[1])?;
let lector = BufReader::new(archivo);
let re_nivel = Regex::new(r"\b(ERROR|WARNING|INFO|DEBUG)\b")?;
let re_timestamp = Regex::new(r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}")?;
for linea in lector.lines() {
let linea = linea?;
// Salta lineas vacias
if linea.trim().is_empty() {
continue;
}
// Extrae nivel si existe
let nivel = re_nivel.find(&linea)
.map(|m| m.as_str())
.unwrap_or("?");
// Extrae timestamp si existe
let ts = re_timestamp.find(&linea)
.map(|m| m.as_str())
.unwrap_or("(sin timestamp)");
println!("[{}] {}: {}", nivel, ts, linea);
}
Ok(())
}
Este script ya hace algo útil: examina cada línea, extrae el nivel de severidad y el timestamp, y los muestra al principio. Es una transformación típica de sed/awk pero con tipado fuerte y sin sorpresas. Y lo mejor: si algo falla, el compilador te avisa antes de ejecutar.
Proyecto: crustaceo-logparse, la herramienta completa
Vale, basta de fragmentos sueltos. Vamos a construir la herramienta completa. Esta es la parte que más me gusta, cuando todo cobra sentido.
crustaceo-logparse es un analizador de logs que hace lo siguiente:
- Lee un archivo de log línea por línea
- Detecta el nivel de severidad (ERROR, WARNING, INFO, DEBUG)
- Extrae el timestamp
- Cuenta ocurrencias por severidad
- Filtra opcionalmente por nivel (solo ERROR, solo WARNING, etc.)
- Muestra resultados formateados en una tabla con colores
- Soporta leer de archivo o de stdin (para pipear)
Este es el Cargo.toml completo (ya lo tienes, pero confirma que está bien):
[package]
name = "crustaceo-logparse"
version = "0.1.0"
edition = "2024"
[dependencies]
regex = "1"
anyhow = "1"
colored = "2"
Y ahora, el código completo de src/main.rs. Prepárate, que viene currada:
use colored::*;
use regex::Regex;
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
use std::path::PathBuf;
/// Representa una linea de log ya parseada
#[derive(Debug)]
struct LogEntry {
linea: usize,
timestamp: String,
nivel: String,
mensaje: String,
raw: String,
}
/// Analiza una linea de log y devuelve un LogEntry si coincide con el formato esperado
fn parsear_linea(num: usize, linea: &str, re: &Regex) -> Option<LogEntry> {
let caps = re.captures(linea)?;
Some(LogEntry {
linea: num,
timestamp: caps["ts"].to_string(),
nivel: caps["nivel"].to_string(),
mensaje: caps["msg"].to_string(),
raw: linea.to_string(),
})
}
/// Muestra el resumen de conteos por severidad en forma de tabla
fn mostrar_resumen(conteos: &BTreeMap<String, usize>, total: usize) {
println!();
println!("{}", "╔════════════════════════════════╗".cyan());
println!("{}", "║ RESUMEN DE ANALISIS DE LOG ║".cyan().bold());
println!("{}", "╚════════════════════════════════╝".cyan());
println!();
// Cabecera de la tabla
println!(
"{} {:>10} {:>10} {:>10}",
"NIVEL".bold(),
"CONTEO".bold(),
"PORCENTAJE".bold(),
"BARRA".bold()
);
println!("{}", "─".repeat(55).dimmed());
let max_nivel = conteos.values().copied().max().unwrap_or(1);
let ancho_barra = 20;
for (nivel, count) in conteos {
let pct = (*count as f64 / total as f64) * 100.0;
let bar_len = (ancho_barra as f64 * *count as f64 / max_nivel as f64).ceil() as usize;
let barra = "█".repeat(bar_len);
let linea_coloreada = match nivel.as_str() {
"ERROR" => format!(
"{} {:>10} {:>9.1}% {}",
nivel.red().bold(),
count.to_string().red(),
pct,
barra.red()
),
"WARNING" => format!(
"{} {:>10} {:>9.1}% {}",
nivel.yellow().bold(),
count.to_string().yellow(),
pct,
barra.yellow()
),
"INFO" => format!(
"{} {:>10} {:>9.1}% {}",
nivel.green().bold(),
count.to_string().green(),
pct,
barra.green()
),
"DEBUG" => format!(
"{} {:>10} {:>9.1}% {}",
nivel.blue().bold(),
count.to_string().blue(),
pct,
barra.blue()
),
_ => format!(
"{} {:>10} {:>9.1}% {}",
nivel.cyan().bold(),
count.to_string().cyan(),
pct,
barra.cyan()
),
};
println!("{}", linea_coloreada);
}
println!("{}", "─".repeat(55).dimmed());
println!(
"{} {:>10}",
"TOTAL".bold().white(),
total.to_string().bold().white()
);
println!();
}
/// Muestra las lineas de log filtradas con formato
fn mostrar_lineas(entradas: &[LogEntry], solo_nivel: Option<&str>) {
let filtradas: Vec<&LogEntry> = match solo_nivel {
Some(nivel) => entradas
.iter()
.filter(|e| e.nivel.to_uppercase() == nivel.to_uppercase())
.collect(),
None => entradas.iter().collect(),
};
if filtradas.is_empty() {
println!("{}", "No se encontraron coincidencias.".yellow());
return;
}
println!();
println!("{}", "╔══════════════════════════════════════════╗".cyan());
if let Some(nivel) = solo_nivel {
println!(
"{}",
format!("║ LINEAS FILTRADAS: {} ({})", nivel.to_uppercase(), filtradas.len())
.cyan()
.bold()
);
} else {
println!(
"{}",
format!("║ TOTAL LINEAS PROCESADAS: {}", filtradas.len())
.cyan()
.bold()
);
}
println!("{}", "╚══════════════════════════════════════════╝".cyan());
println!();
for entrada in &filtradas {
// Colorear segun nivel
let nivel_coloreado = match entrada.nivel.as_str() {
"ERROR" => entrada.nivel.red().bold(),
"WARNING" => entrada.nivel.yellow().bold(),
"INFO" => entrada.nivel.green().bold(),
"DEBUG" => entrada.nivel.blue().bold(),
_ => entrada.nivel.cyan().bold(),
};
println!(
"{} {} {} {}",
format!("{:>6}", entrada.linea).dimmed(),
entrada.timestamp.cyan(),
nivel_coloreado,
entrada.mensaje
);
}
println!();
println!(
"{} {} {}",
"Mostradas".bold(),
filtradas.len().to_string().bold(),
"lineas.".bold()
);
}
fn mostrar_ayuda() {
println!("crustaceo-logparse 0.1.0");
println!("Analizador de archivos de log con filtrado por severidad y resumen estadistico");
println!();
println!("{}", "USO:".bold().underline());
println!(" crustaceo-logparse [OPCIONES] <archivo>");
println!(" crustaceo-logparse [OPCIONES] (lee de stdin)");
println!();
println!("{}", "OPCIONES:".bold().underline());
println!(" -h, --help Muestra esta ayuda");
println!(" -n, --nivel <NIVEL> Filtra por nivel (ERROR, WARNING, INFO, DEBUG)");
println!(" -r, --resumen Muestra solo el resumen (no las lineas)");
println!(" -s, --sort Ordena el resumen por conteo descendente");
println!();
println!("{}", "EJEMPLOS:".bold().underline());
println!(" crustaceo-logparse /var/log/syslog");
println!(" crustaceo-logparse -n ERROR /var/log/syslog");
println!(" cat /var/log/syslog | crustaceo-logparse -r");
println!(" crustaceo-logparse -n WARNING --resumen sistema.log");
println!();
println!("{}", "FORMATO DE LOG SOPORTADO:".bold().underline());
println!(" YYYY-MM-DD HH:MM:SS NIVEL: mensaje");
println!(" Ej: 2024-06-24 10:30:00 ERROR: conexion fallida");
}
fn main() -> anyhow::Result<()> {
let args: Vec<String> = std::env::args().collect();
let mut solo_resumen = false;
let mut solo_nivel: Option<String> = None;
let mut sort_por_conteo = false;
let mut archivos: Vec<String> = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-h" | "--help" => {
mostrar_ayuda();
return Ok(());
}
"-r" | "--resumen" => solo_resumen = true,
"-s" | "--sort" => sort_por_conteo = true,
"-n" | "--nivel" => {
i += 1;
if i >= args.len() {
eprintln!("Error: --nivel requiere un argumento (ERROR|WARNING|INFO|DEBUG)");
std::process::exit(1);
}
solo_nivel = Some(args[i].to_uppercase());
}
arg if arg.starts_with('-') => {
eprintln!("Error: opcion desconocida '{}'", arg);
eprintln!("Uso: {} [-h] [-r] [-n NIVEL] [ARCHIVO]", args[0]);
std::process::exit(1);
}
arg => archivos.push(arg.to_string()),
}
i += 1;
}
// Regex para parsear lineas de log
// Grupos con nombre: ts (timestamp), nivel, msg (mensaje)
let re_log = Regex::new(
r"^(?P<ts>\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})\s+(?P<nivel>\w+):\s*(?P<msg>.*)$"
)?;
// Leer lineas (desde archivo o stdin) y parsear
let mut entradas: Vec<LogEntry> = Vec::new();
let mut conteos: BTreeMap<String, usize> = BTreeMap::new();
let mut no_parseadas: usize = 0;
if archivos.is_empty() {
// Leer de stdin
let stdin = io::stdin();
let lector = stdin.lock();
for (num, linea) in lector.lines().enumerate() {
let linea = linea?;
if linea.trim().is_empty() {
continue;
}
match parsear_linea(num + 1, &linea, &re_log) {
Some(entry) => {
let nivel_up = entry.nivel.to_uppercase();
*conteos.entry(nivel_up.clone()).or_insert(0) += 1;
entradas.push(entry);
}
None => {
no_parseadas += 1;
if !solo_resumen {
println!("{}", format!("[{}] (no parseada): {}", num + 1, linea).dimmed());
}
}
}
}
} else {
// Leer de archivo(s)
for ruta in &archivos {
let path = PathBuf::from(ruta);
let archivo = match File::open(&path) {
Ok(f) => f,
Err(e) => {
eprintln!("{}: {}: {}", "Error".red().bold(), ruta.yellow(), e);
continue;
}
};
let lector = BufReader::new(archivo);
let inicio = entradas.len();
for (num, linea) in lector.lines().enumerate() {
let linea = linea?;
if linea.trim().is_empty() {
continue;
}
match parsear_linea(num + 1, &linea, &re_log) {
Some(entry) => {
let nivel_up = entry.nivel.to_uppercase();
*conteos.entry(nivel_up.clone()).or_insert(0) += 1;
entradas.push(entry);
}
None => {
no_parseadas += 1;
if !solo_resumen {
println!(
"{} ({}:{}) {}",
"[SKIP]".dimmed(),
ruta.yellow(),
(num + 1).to_string().dimmed(),
linea.dimmed()
);
}
}
}
}
let leidas_en_archivo = entradas.len() - inicio;
eprintln!(
"{}: {} lineas parseadas de '{}'",
"OK".green().bold(),
leidas_en_archivo.to_string().green(),
ruta.yellow()
);
}
}
// Si hay lineas no parseadas, informar
if no_parseadas > 0 {
eprintln!(
"{} {} lineas no coinciden con el formato esperado.",
"Advertencia:".yellow().bold(),
no_parseadas
);
}
// Ordenar conteos si se solicito
let conteos_ordenados: Vec<(&String, &usize)> = if sort_por_conteo {
let mut v: Vec<(&String, &usize)> = conteos.iter().collect();
v.sort_by(|a, b| b.1.cmp(a.1));
v
} else {
// Por defecto orden alfabetico (BTreeMap ya lo garantiza)
conteos.iter().collect()
};
// Mostrar resumen
let total = entradas.len();
let mut conteo_ordenado = BTreeMap::new();
for (k, v) in conteos_ordenados {
conteo_ordenado.insert(k.clone(), *v);
}
mostrar_resumen(&conteo_ordenado, total);
// Mostrar lineas (a menos que solo se pida resumen)
if !solo_resumen {
mostrar_lineas(&entradas, solo_nivel.as_deref());
}
Ok(())
}
}
Vamos a desglosar este código pieza por pieza, que tiene miga.
Estructura LogEntry, el contenedor de cada línea
struct LogEntry {
linea: usize,
timestamp: String,
nivel: String,
mensaje: String,
raw: String,
}
Cada línea de log parseada se guarda en esta estructura. linea es el número de línea dentro del archivo (empezando en 1). raw conserva la línea original por si necesitas mostrarla tal cual. Me gusta guardar el original porque en depuración no hay nada como tener la línea completa.
Función parsear_linea, el corazón del asunto
fn parsear_linea(num: usize, linea: &str, re: &Regex) -> Option<LogEntry>
Recibe un número de línea, el texto y la regex compilada. Devuelve Some(LogEntry) si la línea coincide con el patrón, o None si no. Así separamos la lógica de parseo del flujo de lectura.
El operador ? dentro de re.captures(linea)? hace que si no hay capturas, la función retorne None inmediatamente. Limpio y eficiente.
Mapa de conteos con BTreeMap, ordenando el caos
let mut conteos: BTreeMap<String, usize> = BTreeMap::new();
// ...
*conteos.entry(nivel_up.clone()).or_insert(0) += 1;
Usamos BTreeMap en lugar de HashMap porque mantiene las claves ordenadas alfabéticamente. La salida del resumen queda más legible: ERROR, WARNING, INFO, DEBUG en ese orden.
entry(nivel).or_insert(0) devuelve una referencia mutable al valor, y += 1 incrementa el contador. Si la clave no existía, la crea con valor 0 y luego suma 1. Es uno de esos patrones que cuando lo aprendes, te preguntas cómo has vivido sin él.
Tabla de resumen con barras, datos con estilo
La función mostrar_resumen() genera una tabla ASCII con barras de progreso hechas con caracteres █. Calcula el ancho de cada barra proporcional al máximo, y colorea cada nivel según su severidad:
- ERROR → rojo
- WARNING → amarillo
- INFO → verde
- DEBUG → azul
- Otros → cian
Filtrado por nivel, cuando solo te interesan los errores
La opción -n ERROR filtra las líneas mostradas para que solo aparezcan las de ese nivel. El resumen de conteos siempre muestra todos los niveles encontrados, independientemente del filtro.
Lectura desde archivo o stdin, versatilidad total
Si no se pasa ningún archivo como argumento, el programa lee de stdin. Esto permite pipear: cat sistema.log | crustaceo-logparse -n ERROR.
Cuando lee de archivo, muestra un mensaje por cada archivo procesado indicando cuántas líneas se parsearon correctamente. Si hay errores de apertura, los muestra y continúa con el siguiente archivo.
Generar un log de prueba, porque no siempre tienes uno a mano
Para probar crustaceo-logparse necesitas un archivo de log. Puedes usar /var/log/syslog si existe, o generar uno de prueba con este script Bash:
#!/bin/bash
# genera-log-de-prueba.sh - Crea un log de prueba para crustaceo-logparse
LOG="/tmp/sistema.log"
> "$LOG"
FECHA_BASE="2024-06-24"
HORA_BASE=8
declare -a NIVELES=("INFO" "DEBUG" "INFO" "WARNING" "INFO" "ERROR" "INFO" "DEBUG")
declare -a MENSAJES=(
"Servicio web iniciado correctamente en puerto 8080"
"Cache de DNS purgada: 142 entradas eliminadas"
"Peticion GET /api/usuarios completada en 23ms"
"Uso de disco en /var/log al 85% - considerar limpieza"
"Conexion establecida con base de datos postgresql://localhost:5432"
"Timeout en conexion con servidor SMTP: conexion rechazada"
"Backup diario completado: 1.2GB en 34 segundos"
"Depuracion: variable de entorno DB_HOST=localhost"
)
for hora in $(seq 8 18); do
for minuto in $(seq 0 5 55); do
NIVEL="${NIVELES[$((RANDOM % ${#NIVELES[@]}))]}"
MENSAJE="${MENSAJES[$((RANDOM % ${#MENSAJES[@]}))]}"
SEGUNDO=$((RANDOM % 60))
printf "%s %02d:%02d:%02d %s: %s\n" \
"$FECHA_BASE" "$hora" "$minuto" "$SEGUNDO" "$NIVEL" "$MENSAJE" >> "$LOG"
done
done
# Anadir algunas lineas con formato incorrecto para probar el skip
echo "=== linea corrupta que no coincide ===" >> "$LOG"
echo "SOLO MAYUSCULAS SIN TIMESTAMP" >> "$LOG"
echo "" >> "$LOG"
echo "Log de prueba generado: $LOG"
echo "Lineas: $(wc -l < "$LOG")"
Guarda esto como genera-log-de-prueba.sh, dale permisos y ejecútalo:
chmod +x genera-log-de-prueba.sh
./genera-log-de-prueba.sh
Esto genera /tmp/sistema.log con unas 130 líneas de log realistas, mezclando INFO, DEBUG, WARNING y ERROR, más algunas líneas malformadas para probar el manejo de errores.
Verificación, o cómo asegurarte de que todo funciona
# 1. El proyecto compila sin warnings
cd ~/crustaceo-logparse
cargo build 2>&1 | grep -i warning || echo "✅ Sin warnings"
# 2. Analizar el log de prueba completo
./target/debug/crustaceo-logparse /tmp/sistema.log
# Debe mostrar resumen con conteos y todas las lineas
# 3. Filtrar solo errores
./target/debug/crustaceo-logparse -n ERROR /tmp/sistema.log
# Solo lineas con nivel ERROR
# 4. Solo resumen (sin lineas)
./target/debug/crustaceo-logparse -r /tmp/sistema.log
# Solo la tabla resumen, sin listado de lineas
# 5. Combinar filtro y resumen
./target/debug/crustaceo-logparse -n WARNING -r /tmp/sistema.log
# Resumen pero solo mostrando WARNINGs... espera, -r oculta lineas
# Prueba mejor:
./target/debug/crustaceo-logparse -n WARNING /tmp/sistema.log
# Muestra resumen completo + solo lineas WARNING
# 6. Leer desde stdin
cat /tmp/sistema.log | ./target/debug/crustaceo-logparse
# Debe funcionar igual
# 7. Probar con log real del sistema (si tienes permisos)
sudo ./target/debug/crustaceo-logparse -n ERROR /var/log/syslog 2>/dev/null || \
echo "No tienes permisos para leer /var/log/syslog (usa sudo)"
# 8. Probar con archivo inexistente
./target/debug/crustaceo-logparse no-existe.log
# Debe mostrar error y continuar (codigo de salida 0)
# 9. Ayuda
./target/debug/crustaceo-logparse --help
# 10. Release build
cargo build --release
ls -lh target/release/crustaceo-logparse
# ~600-700KB, un solo binario estatico
Salida esperada (aproximada), para que sepas lo que tienes que ver
Cuando ejecutes sobre el log de prueba, verás algo como:
OK: 132 lineas parseadas de '/tmp/sistema.log'
Advertencia: 3 lineas no coinciden con el formato esperado.
╔════════════════════════════════╗
║ RESUMEN DE ANALISIS DE LOG ║
╚════════════════════════════════╝
NIVEL CONTEO PORCENTAJE BARRA
───────────────────────────────────────────────────────
ERROR 18 13.6% ███████████████
INFO 48 36.4% ████████████████████████████████████████
WARNING 33 25.0% ████████████████████████████
DEBUG 33 25.0% ████████████████████████████
───────────────────────────────────────────────────────
TOTAL 132
╔══════════════════════════════════════════╗
║ TOTAL LINEAS PROCESADAS: 132 ║
╚══════════════════════════════════════════╝
164 2024-06-24 08:00:43 DEBUG: Depuracion: variable de entorno DB_HOST=localhost
165 2024-06-24 08:05:03 INFO: Backup diario completado: 1.2GB en 34 segundos
166 2024-06-24 08:10:57 ERROR: Timeout en conexion con servidor SMTP: conexion rechazada
...
Los números de línea pueden variar según el contenido generado. Lo importante es que el parseo funciona, los colores se muestran correctamente y el resumen tiene sentido.
Mejoras que puedes implementar, porque siempre se puede ir más allá
El proyecto es funcional, pero hay muchas formas de extenderlo:
- Soporte para otros formatos de log — añade más patrones regex para logs de Apache, Nginx, systemd journal, Docker, etc.
- Auto-detección de formato — prueba varios patrones y usa el que más coincidencias tenga.
- Salida JSON — flag
--jsonpara integrar con otras herramientas. - Watch mode — flag
--followque monitoriza el archivo en tiempo real (comotail -f). - Filtro por rango de fechas —
--desde "2024-06-24 08:00" --hasta "2024-06-24 12:00". - Exportar a CSV — para analizar en una hoja de cálculo.
- Configuración de colores — archivo de configuración YAML/TOML para personalizar la paleta.
- Múltiples archivos con totales — como hace
crustaceo-statsdel capítulo anterior.
Resumen, o lo que has aprendido hoy
| Concepto | Rust | Bash equivalente |
|---|---|---|
| Leer archivo línea a línea | BufReader::new(File::open(...))?.lines() | while read -r linea |
| Regex básica | Regex::new(r"patron")?.is_match(texto) | grep patron |
| Capturas con regex | re.captures(texto) y caps[1] | [[ $linea =~ (patron) ]] y BASH_REMATCH[1] |
| Capturas con nombre | caps["nombre"] con (?P<nombre>...) | No hay equivalente directo |
| Dividir string | texto.split(':').collect() | IFS=':' read -ra campos |
| Quitar espacios | texto.trim() | xargs o expansión ${var## } |
| Reemplazar texto | texto.replace("a", "b") | ${var//a/b} |
| Contar ocurrencias | BTreeMap con entry().or_insert(0) += 1 | sort | uniq -c |
| Salida coloreada | crate colored | tput setaf 1 o secuencias ANSI |
Has pasado de leer archivos enteros en memoria a procesarlos línea por línea con BufReader. Has aprendido regex en Rust, que son más rápidas y seguras que en casi cualquier otro lenguaje. Y has construido crustaceo-logparse, una herramienta de análisis de logs que ya puedes usar en tu día a día.
En el próximo capítulo veremos cómo gestionar archivos y directorios: leer carpetas, filtrar por extensión, procesar árboles recursivamente. Todo lo que necesitas para escribir scripts de sysadmin que manipulen el sistema de archivos como un pez en el agua.
Más información
- Tutorial Scripts en Bash en atareao.es — el punto de partida para entender qué traes de Bash y cómo traducirlo a Rust
- Tutorial Sed y Awk en atareao.es — procesamiento de texto en Linux, la base conceptual que ahora implementas en Rust
- Crate regex en crates.io — documentación y ejemplos de la crate de regex para Rust
- Documentación de Regex — Rust — API completa con todos los métodos y sintaxis soportada
- Crate anyhow en crates.io — manejo de errores simplificado para aplicaciones
- BufReader — documentación oficial — lectura eficiente con buffer
- std::fs::File — documentación oficial — operaciones de archivo en Rust
- Métodos de String/&str — Rust by Example — split, trim, replace y más
- std::collections::BTreeMap — documentación oficial — mapa ordenado para conteos y agrupaciones
- Crate colored en crates.io — coloración de terminal simple y efectiva