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
Diff de JSON: Adicione um subcomando
diffque compare dois arquivos JSON e mostre as diferenças de forma visual, destacando chaves adicionadas, removidas e modificadas.Merge de JSON: Implemente um subcomando
mergeque combine dois ou mais arquivos JSON, com estratégias configuráveis para resolver conflitos (sobrescrever, concatenar arrays, etc).Conversão JSON para YAML/TOML: Adicione subcomandos para converter entre JSON, YAML e TOML, usando as crates
serde_yamletoml.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.
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
- Módulo io — entrada e saída em Rust
- stdin e stdout — trabalhando com entrada e saída padrão
- Serializar e Desserializar JSON — receita prática com serde_json
- Parse de JSON — parsing de JSON em Rust