Analisador de CSV em Rust

Construa um analisador de arquivos CSV em Rust que calcula estatísticas, filtra linhas e formata resultados. Walkthrough completo.

Arquivos CSV (Comma-Separated Values) estão em toda parte — exportações de planilhas, logs de servidores, dados de sensores, relatórios financeiros. Neste projeto, vamos construir uma ferramenta de linha de comando que lê arquivos CSV, calcula estatísticas descritivas (mínimo, máximo, média, contagem), filtra linhas por condição e apresenta os resultados de forma organizada. Ao construir esse analisador, você vai praticar iteradores, coleta de dados em coleções, parsing de tipos numéricos e a poderosa crate csv.

Este projeto combina vários conceitos fundamentais de Rust: pattern matching, tratamento de erros, iteradores encadeados e generics de forma prática e aplicável ao dia a dia.

O Que Vamos Construir

Nosso csv-analisar terá os seguintes recursos:

  • Leitura de arquivos CSV com detecção automática de cabeçalho
  • Exibição formatada dos dados em tabela
  • Cálculo de estatísticas por coluna numérica (min, max, média, soma, contagem)
  • Filtragem de linhas por condição simples (coluna == valor, coluna > valor)
  • Seleção de colunas específicas para exibição
  • Contagem total de registros

Estrutura do Projeto

csv-analisar/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── cli.rs
    ├── leitor.rs
    ├── estatisticas.rs
    └── formatador.rs

Configurando o Projeto

cargo new csv-analisar
cd csv-analisar

Configure o Cargo.toml:

[package]
name = "csv-analisar"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
csv = "1"
colored = "2"

A crate csv é a biblioteca padrão da comunidade Rust para trabalhar com arquivos CSV. Ela lida com delimitadores, aspas, escaping e muitos outros detalhes do formato.

Passo 1: Definindo a Interface CLI

// src/cli.rs
use clap::{Parser, Subcommand};

#[derive(Parser, Debug)]
#[command(name = "csv-analisar")]
#[command(about = "Ferramenta para análise de arquivos CSV")]
pub struct Cli {
    /// Caminho do arquivo CSV
    pub arquivo: String,

    /// Delimitador (padrão: vírgula)
    #[arg(short, long, default_value = ",")]
    pub delimitador: char,

    #[command(subcommand)]
    pub comando: Comando,
}

#[derive(Subcommand, Debug)]
pub enum Comando {
    /// Exibe os dados do CSV em formato de tabela
    Exibir {
        /// Número máximo de linhas a exibir
        #[arg(short = 'n', long, default_value_t = 50)]
        limite: usize,

        /// Colunas a exibir (separadas por vírgula)
        #[arg(short, long)]
        colunas: Option<String>,
    },

    /// Calcula estatísticas das colunas numéricas
    Estatisticas {
        /// Coluna específica para analisar
        #[arg(short, long)]
        coluna: Option<String>,
    },

    /// Filtra linhas por condição
    Filtrar {
        /// Condição no formato: coluna==valor ou coluna>valor
        condicao: String,

        /// Número máximo de resultados
        #[arg(short = 'n', long, default_value_t = 100)]
        limite: usize,
    },

    /// Exibe informações gerais do arquivo
    Info,
}

Passo 2: Leitura e Parsing do CSV

O módulo leitor.rs encapsula toda a lógica de leitura do arquivo CSV.

// src/leitor.rs
use std::error::Error;
use std::fs::File;

/// Armazena o conteúdo completo de um arquivo CSV
#[derive(Debug)]
pub struct DadosCsv {
    pub cabecalhos: Vec<String>,
    pub linhas: Vec<Vec<String>>,
}

impl DadosCsv {
    /// Lê um arquivo CSV e retorna os dados estruturados
    pub fn ler_arquivo(caminho: &str, delimitador: u8) -> Result<Self, Box<dyn Error>> {
        let arquivo = File::open(caminho)?;

        let mut leitor = csv::ReaderBuilder::new()
            .delimiter(delimitador)
            .has_headers(true)
            .flexible(true)
            .from_reader(arquivo);

        let cabecalhos: Vec<String> = leitor
            .headers()?
            .iter()
            .map(|h| h.trim().to_string())
            .collect();

        let mut linhas = Vec::new();

        for resultado in leitor.records() {
            let registro = resultado?;
            let linha: Vec<String> = registro
                .iter()
                .map(|campo| campo.trim().to_string())
                .collect();
            linhas.push(linha);
        }

        Ok(Self { cabecalhos, linhas })
    }

    /// Retorna o índice de uma coluna pelo nome
    pub fn indice_coluna(&self, nome: &str) -> Option<usize> {
        self.cabecalhos
            .iter()
            .position(|h| h.eq_ignore_ascii_case(nome))
    }

    /// Retorna os valores de uma coluna específica
    pub fn valores_coluna(&self, indice: usize) -> Vec<&str> {
        self.linhas
            .iter()
            .filter_map(|linha| linha.get(indice).map(|s| s.as_str()))
            .collect()
    }

    /// Filtra linhas com base numa condição simples
    pub fn filtrar(&self, coluna_idx: usize, operador: &str, valor: &str) -> Vec<&Vec<String>> {
        self.linhas
            .iter()
            .filter(|linha| {
                if let Some(campo) = linha.get(coluna_idx) {
                    comparar(campo, operador, valor)
                } else {
                    false
                }
            })
            .collect()
    }
}

/// Compara um campo com um valor usando o operador especificado
fn comparar(campo: &str, operador: &str, valor: &str) -> bool {
    match operador {
        "==" => campo.eq_ignore_ascii_case(valor),
        "!=" => !campo.eq_ignore_ascii_case(valor),
        ">" => {
            if let (Ok(a), Ok(b)) = (campo.parse::<f64>(), valor.parse::<f64>()) {
                a > b
            } else {
                campo > valor
            }
        }
        "<" => {
            if let (Ok(a), Ok(b)) = (campo.parse::<f64>(), valor.parse::<f64>()) {
                a < b
            } else {
                campo < valor
            }
        }
        ">=" => {
            if let (Ok(a), Ok(b)) = (campo.parse::<f64>(), valor.parse::<f64>()) {
                a >= b
            } else {
                campo >= valor
            }
        }
        "<=" => {
            if let (Ok(a), Ok(b)) = (campo.parse::<f64>(), valor.parse::<f64>()) {
                a <= b
            } else {
                campo <= valor
            }
        }
        _ => false,
    }
}

/// Extrai operador e partes de uma string de condição como "coluna>=valor"
pub fn parsear_condicao(condicao: &str) -> Option<(String, String, String)> {
    let operadores = [">=", "<=", "!=", "==", ">", "<"];

    for op in &operadores {
        if let Some(pos) = condicao.find(op) {
            let coluna = condicao[..pos].trim().to_string();
            let valor = condicao[pos + op.len()..].trim().to_string();
            return Some((coluna, op.to_string(), valor));
        }
    }

    None
}

A struct DadosCsv armazena os cabeçalhos e todas as linhas em memória. Para arquivos muito grandes, seria melhor usar streaming, mas para uma ferramenta de análise exploratória, manter tudo na memória simplifica bastante o código.

Passo 3: Cálculo de Estatísticas

// src/estatisticas.rs

/// Resultado das estatísticas de uma coluna numérica
#[derive(Debug)]
pub struct EstatisticasColuna {
    pub nome: String,
    pub contagem: usize,
    pub valores_numericos: usize,
    pub minimo: Option<f64>,
    pub maximo: Option<f64>,
    pub soma: f64,
    pub media: Option<f64>,
}

/// Calcula estatísticas para uma coluna de valores
pub fn calcular(nome: &str, valores: &[&str]) -> EstatisticasColuna {
    let numeros: Vec<f64> = valores
        .iter()
        .filter_map(|v| v.parse::<f64>().ok())
        .collect();

    let contagem = valores.len();
    let valores_numericos = numeros.len();

    let (minimo, maximo, soma, media) = if numeros.is_empty() {
        (None, None, 0.0, None)
    } else {
        let min = numeros.iter().cloned().fold(f64::INFINITY, f64::min);
        let max = numeros.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
        let soma: f64 = numeros.iter().sum();
        let media = soma / numeros.len() as f64;
        (Some(min), Some(max), soma, Some(media))
    };

    EstatisticasColuna {
        nome: nome.to_string(),
        contagem,
        valores_numericos,
        minimo,
        maximo,
        soma,
        media,
    }
}

Passo 4: Formatação e Exibição

// src/formatador.rs
use crate::estatisticas::EstatisticasColuna;
use colored::*;

/// Exibe dados em formato de tabela com larguras calculadas
pub fn exibir_tabela(cabecalhos: &[String], linhas: &[&Vec<String>], limite: usize) {
    // Calcular a largura máxima de cada coluna
    let mut larguras: Vec<usize> = cabecalhos.iter().map(|h| h.len()).collect();

    for linha in linhas.iter().take(limite) {
        for (i, campo) in linha.iter().enumerate() {
            if i < larguras.len() && campo.len() > larguras[i] {
                larguras[i] = campo.len();
            }
        }
    }

    // Limitar largura máxima a 40 caracteres
    for l in larguras.iter_mut() {
        if *l > 40 {
            *l = 40;
        }
    }

    // Cabeçalhos
    let cab: Vec<String> = cabecalhos
        .iter()
        .enumerate()
        .map(|(i, h)| format!("{:width$}", h, width = larguras.get(i).copied().unwrap_or(10)))
        .collect();
    println!("{}", cab.join(" | ").bold().cyan());

    // Separador
    let sep: Vec<String> = larguras.iter().map(|l| "-".repeat(*l)).collect();
    println!("{}", sep.join("-+-"));

    // Linhas de dados
    let total = linhas.len();
    for linha in linhas.iter().take(limite) {
        let campos: Vec<String> = linha
            .iter()
            .enumerate()
            .map(|(i, c)| {
                let largura = larguras.get(i).copied().unwrap_or(10);
                let truncado = if c.len() > largura {
                    format!("{}...", &c[..largura - 3])
                } else {
                    c.clone()
                };
                format!("{:width$}", truncado, width = largura)
            })
            .collect();
        println!("{}", campos.join(" | "));
    }

    if total > limite {
        println!(
            "\n{} Exibindo {} de {} registros.",
            "INFO:".blue().bold(),
            limite,
            total
        );
    }
}

/// Exibe estatísticas de uma coluna
pub fn exibir_estatisticas(estat: &EstatisticasColuna) {
    println!("{}", format!("Coluna: {}", estat.nome).bold().cyan());
    println!("  Registros totais:   {}", estat.contagem);
    println!("  Valores numéricos:  {}", estat.valores_numericos);

    if let Some(min) = estat.minimo {
        println!("  Mínimo:             {:.2}", min);
    }
    if let Some(max) = estat.maximo {
        println!("  Máximo:             {:.2}", max);
    }
    if estat.valores_numericos > 0 {
        println!("  Soma:               {:.2}", estat.soma);
    }
    if let Some(media) = estat.media {
        println!("  Média:              {:.2}", media);
    }

    println!();
}

Passo 5: Integrando no main.rs

// src/main.rs
mod cli;
mod estatisticas;
mod formatador;
mod leitor;

use clap::Parser;
use cli::{Cli, Comando};
use colored::*;

fn main() {
    let cli = Cli::parse();

    let delimitador = cli.delimitador as u8;

    let dados = match leitor::DadosCsv::ler_arquivo(&cli.arquivo, delimitador) {
        Ok(d) => d,
        Err(e) => {
            eprintln!(
                "{} Não foi possível ler '{}': {}",
                "ERRO:".red().bold(),
                cli.arquivo,
                e
            );
            std::process::exit(1);
        }
    };

    match cli.comando {
        Comando::Info => {
            println!("{}", "Informações do arquivo CSV".bold().cyan());
            println!("  Arquivo:     {}", cli.arquivo);
            println!("  Colunas:     {}", dados.cabecalhos.len());
            println!("  Registros:   {}", dados.linhas.len());
            println!("  Cabeçalhos:  {}", dados.cabecalhos.join(", "));

            // Detectar colunas numéricas
            let numericas: Vec<&String> = dados
                .cabecalhos
                .iter()
                .enumerate()
                .filter(|(i, _)| {
                    let valores = dados.valores_coluna(*i);
                    let num_count = valores.iter().filter(|v| v.parse::<f64>().is_ok()).count();
                    num_count > valores.len() / 2
                })
                .map(|(_, nome)| nome)
                .collect();

            if !numericas.is_empty() {
                println!("  Numéricas:   {}", numericas.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
            }
        }

        Comando::Exibir { limite, colunas } => {
            let (cabecalhos_filtrados, linhas_filtradas) = if let Some(cols) = colunas {
                let nomes: Vec<&str> = cols.split(',').map(|s| s.trim()).collect();
                let indices: Vec<usize> = nomes
                    .iter()
                    .filter_map(|nome| dados.indice_coluna(nome))
                    .collect();

                let cab: Vec<String> = indices
                    .iter()
                    .filter_map(|i| dados.cabecalhos.get(*i).cloned())
                    .collect();

                let lins: Vec<Vec<String>> = dados
                    .linhas
                    .iter()
                    .map(|linha| {
                        indices
                            .iter()
                            .filter_map(|i| linha.get(*i).cloned())
                            .collect()
                    })
                    .collect();

                (cab, lins)
            } else {
                (dados.cabecalhos.clone(), dados.linhas.clone())
            };

            let refs: Vec<&Vec<String>> = linhas_filtradas.iter().collect();
            formatador::exibir_tabela(&cabecalhos_filtrados, &refs, limite);
        }

        Comando::Estatisticas { coluna } => {
            let indices: Vec<(usize, &String)> = if let Some(nome_coluna) = &coluna {
                if let Some(idx) = dados.indice_coluna(nome_coluna) {
                    vec![(idx, &dados.cabecalhos[idx])]
                } else {
                    eprintln!(
                        "{} Coluna '{}' não encontrada.",
                        "ERRO:".red().bold(),
                        nome_coluna
                    );
                    std::process::exit(1);
                }
            } else {
                dados.cabecalhos.iter().enumerate().collect()
            };

            println!("{}\n", "Estatísticas do CSV".bold().cyan());

            for (idx, nome) in &indices {
                let valores = dados.valores_coluna(*idx);
                let estat = estatisticas::calcular(nome, &valores);

                // Só exibir se tiver valores numéricos
                if estat.valores_numericos > 0 {
                    formatador::exibir_estatisticas(&estat);
                }
            }
        }

        Comando::Filtrar { condicao, limite } => {
            let (col_nome, operador, valor) = match leitor::parsear_condicao(&condicao) {
                Some(c) => c,
                None => {
                    eprintln!(
                        "{} Condição inválida: '{}'. Use formato: coluna==valor",
                        "ERRO:".red().bold(),
                        condicao
                    );
                    std::process::exit(1);
                }
            };

            let col_idx = match dados.indice_coluna(&col_nome) {
                Some(i) => i,
                None => {
                    eprintln!(
                        "{} Coluna '{}' não encontrada.",
                        "ERRO:".red().bold(),
                        col_nome
                    );
                    std::process::exit(1);
                }
            };

            let resultados = dados.filtrar(col_idx, &operador, &valor);

            println!(
                "{} {} registro(s) encontrado(s) para: {}\n",
                "FILTRO:".blue().bold(),
                resultados.len(),
                condicao
            );

            let limitados: Vec<&Vec<String>> = resultados.into_iter().take(limite).collect();
            formatador::exibir_tabela(&dados.cabecalhos, &limitados, limite);
        }
    }
}

Como Executar

cargo build --release

Crie um arquivo CSV de exemplo (vendas.csv):

produto,categoria,preco,quantidade,cidade
Notebook,Eletrônicos,3500.00,15,São Paulo
Mouse,Periféricos,89.90,120,Rio de Janeiro
Teclado,Periféricos,199.90,85,Belo Horizonte
Monitor,Eletrônicos,1200.00,30,São Paulo
Webcam,Periféricos,249.00,45,Curitiba
SSD,Armazenamento,350.00,200,São Paulo

Exemplos de uso:

# Ver informações gerais
./target/release/csv-analisar vendas.csv info
# Colunas: 5, Registros: 6, Numéricas: preco, quantidade

# Exibir tabela formatada
./target/release/csv-analisar vendas.csv exibir

# Exibir apenas colunas específicas
./target/release/csv-analisar vendas.csv exibir --colunas "produto,preco,quantidade"

# Calcular estatísticas
./target/release/csv-analisar vendas.csv estatisticas
# Coluna: preco
#   Mínimo: 89.90, Máximo: 3500.00, Média: 931.47

# Estatísticas de uma coluna
./target/release/csv-analisar vendas.csv estatisticas --coluna quantidade

# Filtrar registros
./target/release/csv-analisar vendas.csv filtrar "cidade==São Paulo"
./target/release/csv-analisar vendas.csv filtrar "preco>500"
./target/release/csv-analisar vendas.csv filtrar "categoria==Periféricos"

Desafios para Expandir

  1. Ordenação por coluna: Adicione um subcomando ordenar que ordene os registros por uma coluna escolhida (ascendente ou descendente), tratando valores numéricos e textuais.

  2. Agrupamento e agregação: Implemente agrupar-por que agrupe registros por uma coluna e calcule soma/média/contagem para outra (ex: total de vendas por cidade).

  3. Exportação para outros formatos: Adicione a opção de exportar os dados filtrados para JSON ou Markdown, facilitando a integração com relatórios.

  4. Suporte a múltiplos delimitadores: Detecte automaticamente se o separador é vírgula, ponto-e-vírgula, tab ou pipe, analisando as primeiras linhas do arquivo.

  5. Gráfico ASCII de barras: Crie uma visualização simples em ASCII que mostre um gráfico de barras horizontal para as estatísticas de uma coluna numérica.

Veja Também