JSON Formatter em Rust: Projeto Prático | Rust Brasil

Construa um formatador e validador JSON em Rust com pretty-print, minificação e serde. Projeto prático passo a passo.

JSON é o formato de dados mais utilizado em APIs web, configurações de aplicativos e troca de dados entre sistemas. Porém, frequentemente recebemos JSON minificado (em uma única linha) que é difícil de ler, ou precisamos validar se um arquivo JSON está sintaticamente correto antes de processá-lo. Neste projeto, vamos construir uma ferramenta de linha de comando que formata, valida e manipula documentos JSON.

Este projeto é perfeito para aprender sobre entrada e saída em Rust (stdin, stdout, arquivos), parsing de dados estruturados com serde_json e o design de ferramentas que funcionam bem em pipelines de shell.

O Que Vamos Construir

Nosso jsonf (JSON Formatter) terá os seguintes recursos:

  • Pretty-print com indentação configurável
  • Minificação (compactação em uma linha)
  • Validação com mensagens de erro detalhadas
  • Leitura de stdin ou de arquivo
  • Extração de valores por caminho (query simples)
  • Ordenação de chaves
  • Saída colorida no terminal

Estrutura do Projeto

jsonf/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── cli.rs
    ├── formatador.rs
    └── consulta.rs

Configurando o Projeto

cargo new jsonf
cd jsonf

Configure o Cargo.toml:

[package]
name = "jsonf"
version = "0.1.0"
edition = "2021"

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

Usamos serde_json como o motor de parsing JSON — é a biblioteca mais utilizada do ecossistema Rust para trabalhar com JSON, rápida e robusta.

Passo 1: Interface de Linha de Comando

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

#[derive(Parser, Debug)]
#[command(name = "jsonf")]
#[command(about = "Formatador e validador JSON para o terminal")]
pub struct Cli {
    #[command(subcommand)]
    pub comando: Comando,
}

#[derive(Subcommand, Debug)]
pub enum Comando {
    /// Formata JSON com indentação (pretty-print)
    Formatar {
        /// Arquivo JSON de entrada (ou - para stdin)
        #[arg(default_value = "-")]
        entrada: String,

        /// Número de espaços para indentação
        #[arg(short, long, default_value_t = 2)]
        espacos: usize,

        /// Ordenar chaves alfabeticamente
        #[arg(short = 'o', long)]
        ordenar_chaves: bool,

        /// Arquivo de saída (padrão: stdout)
        #[arg(short = 's', long)]
        saida: Option<String>,
    },

    /// Minifica JSON (remove espaços e quebras de linha)
    Minificar {
        /// Arquivo JSON de entrada (ou - para stdin)
        #[arg(default_value = "-")]
        entrada: String,

        /// Arquivo de saída (padrão: stdout)
        #[arg(short = 's', long)]
        saida: Option<String>,
    },

    /// Valida se o JSON é sintaticamente correto
    Validar {
        /// Arquivo JSON de entrada (ou - para stdin)
        #[arg(default_value = "-")]
        entrada: String,

        /// Exibir informações detalhadas sobre a estrutura
        #[arg(short, long)]
        detalhes: bool,
    },

    /// Extrai um valor por caminho (ex: "usuario.nome")
    Consultar {
        /// Arquivo JSON de entrada (ou - para stdin)
        #[arg(default_value = "-")]
        entrada: String,

        /// Caminho separado por pontos (ex: "dados.lista.0.nome")
        #[arg(short, long)]
        caminho: String,
    },
}

Passo 2: Funções de Formatação

O módulo formatador.rs contém a lógica central de leitura, formatação e validação.

// src/formatador.rs
use colored::*;
use serde_json::Value;
use std::fs;
use std::io::{self, Read};

/// Lê JSON de um arquivo ou de stdin
pub fn ler_entrada(origem: &str) -> Result<String, io::Error> {
    if origem == "-" {
        let mut conteudo = String::new();
        io::stdin().read_to_string(&mut conteudo)?;
        Ok(conteudo)
    } else {
        fs::read_to_string(origem)
    }
}

/// Escreve o resultado em um arquivo ou em stdout
pub fn escrever_saida(destino: &Option<String>, conteudo: &str) -> Result<(), io::Error> {
    match destino {
        Some(arquivo) => {
            fs::write(arquivo, conteudo)?;
            eprintln!(
                "{} Resultado escrito em: {}",
                "OK".green().bold(),
                arquivo
            );
            Ok(())
        }
        None => {
            println!("{}", conteudo);
            Ok(())
        }
    }
}

/// Formata JSON com pretty-print e indentação configurável
pub fn formatar(json_str: &str, espacos: usize, ordenar: bool) -> Result<String, String> {
    let valor: Value = serde_json::from_str(json_str)
        .map_err(|e| format!("JSON inválido: {}", e))?;

    let valor_final = if ordenar {
        ordenar_chaves_recursivo(&valor)
    } else {
        valor
    };

    // Usar formatação personalizada com o número de espaços desejado
    let formatador = PrettyFormatter::new(espacos);
    let resultado = formatador.formatar(&valor_final);
    Ok(resultado)
}

/// Minifica JSON removendo espaços e quebras de linha
pub fn minificar(json_str: &str) -> Result<String, String> {
    let valor: Value = serde_json::from_str(json_str)
        .map_err(|e| format!("JSON inválido: {}", e))?;

    serde_json::to_string(&valor)
        .map_err(|e| format!("Erro ao serializar: {}", e))
}

/// Valida JSON e retorna informações sobre a estrutura
pub fn validar(json_str: &str) -> Result<InfoJson, String> {
    let valor: Value = serde_json::from_str(json_str)
        .map_err(|e| format!("{}", e))?;

    let info = analisar_estrutura(&valor);
    Ok(info)
}

/// Informações sobre a estrutura do JSON
#[derive(Debug)]
pub struct InfoJson {
    pub tipo_raiz: String,
    pub profundidade_maxima: usize,
    pub total_chaves: usize,
    pub total_valores: usize,
    pub total_arrays: usize,
    pub total_objetos: usize,
    pub total_strings: usize,
    pub total_numeros: usize,
    pub total_booleans: usize,
    pub total_nulos: usize,
    pub tamanho_minificado: usize,
}

fn analisar_estrutura(valor: &Value) -> InfoJson {
    let mut info = InfoJson {
        tipo_raiz: tipo_valor(valor).to_string(),
        profundidade_maxima: 0,
        total_chaves: 0,
        total_valores: 0,
        total_arrays: 0,
        total_objetos: 0,
        total_strings: 0,
        total_numeros: 0,
        total_booleans: 0,
        total_nulos: 0,
        tamanho_minificado: serde_json::to_string(valor).unwrap_or_default().len(),
    };

    contar_recursivo(valor, 0, &mut info);
    info
}

fn contar_recursivo(valor: &Value, profundidade: usize, info: &mut InfoJson) {
    if profundidade > info.profundidade_maxima {
        info.profundidade_maxima = profundidade;
    }

    match valor {
        Value::Object(mapa) => {
            info.total_objetos += 1;
            info.total_chaves += mapa.len();
            for (_chave, v) in mapa {
                info.total_valores += 1;
                contar_recursivo(v, profundidade + 1, info);
            }
        }
        Value::Array(lista) => {
            info.total_arrays += 1;
            for v in lista {
                info.total_valores += 1;
                contar_recursivo(v, profundidade + 1, info);
            }
        }
        Value::String(_) => info.total_strings += 1,
        Value::Number(_) => info.total_numeros += 1,
        Value::Bool(_) => info.total_booleans += 1,
        Value::Null => info.total_nulos += 1,
    }
}

fn tipo_valor(valor: &Value) -> &str {
    match valor {
        Value::Object(_) => "Objeto",
        Value::Array(_) => "Array",
        Value::String(_) => "String",
        Value::Number(_) => "Número",
        Value::Bool(_) => "Boolean",
        Value::Null => "Null",
    }
}

/// Ordena as chaves de objetos JSON recursivamente
fn ordenar_chaves_recursivo(valor: &Value) -> Value {
    match valor {
        Value::Object(mapa) => {
            let mut novo: serde_json::Map<String, Value> = serde_json::Map::new();
            let mut chaves: Vec<&String> = mapa.keys().collect();
            chaves.sort();

            for chave in chaves {
                if let Some(v) = mapa.get(chave) {
                    novo.insert(chave.clone(), ordenar_chaves_recursivo(v));
                }
            }
            Value::Object(novo)
        }
        Value::Array(lista) => {
            Value::Array(lista.iter().map(ordenar_chaves_recursivo).collect())
        }
        outro => outro.clone(),
    }
}

/// Formatador personalizado com indentação configurável
struct PrettyFormatter {
    espacos: usize,
}

impl PrettyFormatter {
    fn new(espacos: usize) -> Self {
        Self { espacos }
    }

    fn formatar(&self, valor: &Value) -> String {
        let mut resultado = String::new();
        self.formatar_valor(valor, 0, &mut resultado);
        resultado
    }

    fn formatar_valor(&self, valor: &Value, nivel: usize, saida: &mut String) {
        match valor {
            Value::Object(mapa) => {
                if mapa.is_empty() {
                    saida.push_str("{}");
                    return;
                }
                saida.push_str("{\n");
                let total = mapa.len();
                for (i, (chave, v)) in mapa.iter().enumerate() {
                    self.indentar(nivel + 1, saida);
                    saida.push_str(&format!("\"{}\": ", chave));
                    self.formatar_valor(v, nivel + 1, saida);
                    if i < total - 1 {
                        saida.push(',');
                    }
                    saida.push('\n');
                }
                self.indentar(nivel, saida);
                saida.push('}');
            }
            Value::Array(lista) => {
                if lista.is_empty() {
                    saida.push_str("[]");
                    return;
                }
                saida.push_str("[\n");
                let total = lista.len();
                for (i, v) in lista.iter().enumerate() {
                    self.indentar(nivel + 1, saida);
                    self.formatar_valor(v, nivel + 1, saida);
                    if i < total - 1 {
                        saida.push(',');
                    }
                    saida.push('\n');
                }
                self.indentar(nivel, saida);
                saida.push(']');
            }
            Value::String(s) => {
                saida.push_str(&format!("\"{}\"", s));
            }
            Value::Number(n) => {
                saida.push_str(&n.to_string());
            }
            Value::Bool(b) => {
                saida.push_str(&b.to_string());
            }
            Value::Null => {
                saida.push_str("null");
            }
        }
    }

    fn indentar(&self, nivel: usize, saida: &mut String) {
        for _ in 0..nivel * self.espacos {
            saida.push(' ');
        }
    }
}

/// Exibe informações detalhadas sobre o JSON
pub fn exibir_info(info: &InfoJson) {
    println!("{}", "Estrutura do JSON".bold().cyan());
    println!("  Tipo raiz:         {}", info.tipo_raiz);
    println!("  Profundidade máx.: {}", info.profundidade_maxima);
    println!("  Objetos:           {}", info.total_objetos);
    println!("  Arrays:            {}", info.total_arrays);
    println!("  Total de chaves:   {}", info.total_chaves);
    println!("  Total de valores:  {}", info.total_valores);
    println!("  Strings:           {}", info.total_strings);
    println!("  Números:           {}", info.total_numeros);
    println!("  Booleans:          {}", info.total_booleans);
    println!("  Nulos:             {}", info.total_nulos);
    println!("  Tamanho (minif.):  {} bytes", info.tamanho_minificado);
}

Passo 3: Sistema de Consultas por Caminho

// src/consulta.rs
use serde_json::Value;

/// Extrai um valor do JSON usando um caminho separado por pontos
/// Exemplo: "usuario.enderecos.0.cidade"
pub fn consultar(valor: &Value, caminho: &str) -> Option<Value> {
    let partes: Vec<&str> = caminho.split('.').collect();
    let mut atual = valor;

    for parte in partes {
        match atual {
            Value::Object(mapa) => {
                atual = mapa.get(parte)?;
            }
            Value::Array(lista) => {
                let indice: usize = parte.parse().ok()?;
                atual = lista.get(indice)?;
            }
            _ => return None,
        }
    }

    Some(atual.clone())
}

O sistema de consultas permite navegar pela estrutura JSON usando caminhos como usuario.nome ou itens.0.preco, onde números representam índices de arrays.

Passo 4: Integrando no main.rs

// src/main.rs
mod cli;
mod consulta;
mod formatador;

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

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

    match cli.comando {
        Comando::Formatar {
            entrada,
            espacos,
            ordenar_chaves,
            saida,
        } => {
            let json_str = ler_ou_sair(&entrada);
            match formatador::formatar(&json_str, espacos, ordenar_chaves) {
                Ok(resultado) => {
                    if let Err(e) = formatador::escrever_saida(&saida, &resultado) {
                        eprintln!("{} {}", "ERRO:".red().bold(), e);
                        std::process::exit(1);
                    }
                }
                Err(e) => {
                    eprintln!("{} {}", "ERRO:".red().bold(), e);
                    std::process::exit(1);
                }
            }
        }

        Comando::Minificar { entrada, saida } => {
            let json_str = ler_ou_sair(&entrada);
            match formatador::minificar(&json_str) {
                Ok(resultado) => {
                    let economia = if json_str.len() > 0 {
                        100.0 - (resultado.len() as f64 / json_str.len() as f64 * 100.0)
                    } else {
                        0.0
                    };

                    if let Err(e) = formatador::escrever_saida(&saida, &resultado) {
                        eprintln!("{} {}", "ERRO:".red().bold(), e);
                        std::process::exit(1);
                    }

                    eprintln!(
                        "{} {} -> {} bytes ({:.1}% de redução)",
                        "INFO:".blue().bold(),
                        json_str.len(),
                        resultado.len(),
                        economia
                    );
                }
                Err(e) => {
                    eprintln!("{} {}", "ERRO:".red().bold(), e);
                    std::process::exit(1);
                }
            }
        }

        Comando::Validar { entrada, detalhes } => {
            let json_str = ler_ou_sair(&entrada);
            match formatador::validar(&json_str) {
                Ok(info) => {
                    println!(
                        "{} JSON válido!",
                        "OK".green().bold()
                    );
                    if detalhes {
                        println!();
                        formatador::exibir_info(&info);
                    }
                }
                Err(e) => {
                    eprintln!(
                        "{} JSON inválido: {}",
                        "ERRO:".red().bold(),
                        e
                    );
                    std::process::exit(1);
                }
            }
        }

        Comando::Consultar { entrada, caminho } => {
            let json_str = ler_ou_sair(&entrada);
            let valor: serde_json::Value = match serde_json::from_str(&json_str) {
                Ok(v) => v,
                Err(e) => {
                    eprintln!("{} JSON inválido: {}", "ERRO:".red().bold(), e);
                    std::process::exit(1);
                }
            };

            match consulta::consultar(&valor, &caminho) {
                Some(resultado) => {
                    let formatado = serde_json::to_string_pretty(&resultado)
                        .unwrap_or_else(|_| resultado.to_string());
                    println!("{}", formatado);
                }
                None => {
                    eprintln!(
                        "{} Caminho '{}' não encontrado no JSON.",
                        "ERRO:".red().bold(),
                        caminho
                    );
                    std::process::exit(1);
                }
            }
        }
    }
}

fn ler_ou_sair(entrada: &str) -> String {
    match formatador::ler_entrada(entrada) {
        Ok(s) => s,
        Err(e) => {
            eprintln!("{} Não foi possível ler '{}': {}", "ERRO:".red().bold(), entrada, e);
            std::process::exit(1);
        }
    }
}

Como Executar

cargo build --release

Exemplos de uso:

# Formatar JSON de um arquivo
./target/release/jsonf formatar dados.json

# Formatar com 4 espaços de indentação e chaves ordenadas
./target/release/jsonf formatar dados.json --espacos 4 -o

# Formatar JSON recebido via pipe
echo '{"nome":"João","idade":30}' | ./target/release/jsonf formatar

# Minificar um arquivo JSON
./target/release/jsonf minificar dados_formatados.json
# INFO: 1250 -> 890 bytes (28.8% de redução)

# Validar JSON
./target/release/jsonf validar config.json
# OK JSON válido!

# Validar com detalhes da estrutura
./target/release/jsonf validar dados.json --detalhes
# OK JSON válido!
# Tipo raiz: Objeto, Profundidade: 3, Objetos: 5, Arrays: 2

# Consultar um valor por caminho
echo '{"usuario":{"nome":"Maria","endereco":{"cidade":"São Paulo"}}}' \
  | ./target/release/jsonf consultar --caminho "usuario.endereco.cidade"
# "São Paulo"

# Salvar resultado em arquivo
./target/release/jsonf formatar entrada.json -s saida_formatada.json

Desafios para Expandir

  1. Diff de JSON: Adicione um subcomando diff que compare dois arquivos JSON e mostre as diferenças de forma visual, destacando chaves adicionadas, removidas e modificadas.

  2. Merge de JSON: Implemente um subcomando merge que combine dois ou mais arquivos JSON, com estratégias configuráveis para resolver conflitos (sobrescrever, concatenar arrays, etc).

  3. Conversão JSON para YAML/TOML: Adicione subcomandos para converter entre JSON, YAML e TOML, usando as crates serde_yaml e toml.

  4. Saída com syntax highlighting: Aplique cores diferentes para chaves (azul), strings (verde), números (amarelo), booleans (magenta) e null (cinza) ao exibir no terminal.

  5. Schema validation: Implemente validação contra um JSON Schema, verificando se o documento segue uma estrutura predefinida com tipos, obrigatoriedade e formatos.

Veja Também