Analisador de Uso de Disco

Construa um analisador de uso de disco em Rust similar ao comando du, com varredura recursiva e tamanhos legíveis por humanos.

Saber quanto espaço cada diretório ocupa no disco é fundamental para administração de sistemas, limpeza de espaço e diagnóstico de problemas de armazenamento. O comando du (disk usage) do Unix faz exatamente isso, e neste projeto vamos construir uma versão simplificada mas funcional em Rust. Ao percorrer a árvore de diretórios, calcular tamanhos acumulados e apresentar os resultados de forma organizada, você vai aprender sobre o módulo std::fs, a crate walkdir, formatação de dados e ordenação de coleções.

Este projeto combina operações de sistema de arquivos com processamento de dados e formatação — habilidades essenciais para qualquer ferramenta de DevOps ou administração de sistemas escrita em Rust.

O Que Vamos Construir

Nosso espaco terá os seguintes recursos:

  • Varredura recursiva de diretórios
  • Cálculo acumulado de tamanho por diretório
  • Tamanhos legíveis por humanos (KB, MB, GB)
  • Ordenação por tamanho (maiores primeiro)
  • Limitação do número de resultados exibidos
  • Profundidade máxima de varredura configurável
  • Barra visual de proporção

Estrutura do Projeto

espaco/
├── Cargo.toml
└── src/
    ├── main.rs
    ├── cli.rs
    ├── scanner.rs
    └── formatador.rs

Configurando o Projeto

cargo new espaco
cd espaco

Configure o Cargo.toml:

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

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

A crate walkdir facilita a varredura recursiva de diretórios, tratando erros de permissão e symlinks automaticamente.

Passo 1: Interface de Linha de Comando

// src/cli.rs
use clap::Parser;

#[derive(Parser, Debug)]
#[command(name = "espaco")]
#[command(about = "Analisador de uso de disco — mostra o tamanho de diretórios")]
pub struct Cli {
    /// Diretório a analisar (padrão: diretório atual)
    #[arg(default_value = ".")]
    pub diretorio: String,

    /// Profundidade máxima de varredura
    #[arg(short = 'p', long, default_value_t = 3)]
    pub profundidade: usize,

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

    /// Ordenar por tamanho (maiores primeiro)
    #[arg(short, long, default_value_t = true)]
    pub ordenar: bool,

    /// Exibir somente diretórios (ignorar arquivos individuais)
    #[arg(short = 'd', long)]
    pub somente_diretorios: bool,

    /// Tamanho mínimo para exibir (em bytes, ex: 1048576 para 1MB)
    #[arg(short = 'm', long, default_value_t = 0)]
    pub minimo: u64,

    /// Exibir barra visual de proporção
    #[arg(short = 'b', long, default_value_t = true)]
    pub barra: bool,
}

Passo 2: Scanner de Diretórios

// src/scanner.rs
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

/// Representa um item (arquivo ou diretório) com seu tamanho
#[derive(Debug, Clone)]
pub struct ItemDisco {
    pub caminho: PathBuf,
    pub tamanho: u64,
    pub e_diretorio: bool,
    pub profundidade: usize,
}

/// Resultado da varredura completa
pub struct ResultadoVarredura {
    pub itens: Vec<ItemDisco>,
    pub tamanho_total: u64,
    pub total_arquivos: usize,
    pub total_diretorios: usize,
    pub erros: usize,
}

/// Varre o diretório e calcula tamanhos acumulados
pub fn varrer_diretorio(
    caminho_raiz: &str,
    profundidade_max: usize,
    somente_diretorios: bool,
) -> ResultadoVarredura {
    let raiz = Path::new(caminho_raiz);
    let mut tamanhos_dirs: HashMap<PathBuf, u64> = HashMap::new();
    let mut total_arquivos: usize = 0;
    let mut total_diretorios: usize = 0;
    let mut erros: usize = 0;

    // Primeira passada: coletar tamanhos de todos os arquivos
    for entrada in WalkDir::new(raiz)
        .into_iter()
    {
        match entrada {
            Ok(e) => {
                if e.file_type().is_file() {
                    total_arquivos += 1;
                    let tamanho = e.metadata().map(|m| m.len()).unwrap_or(0);

                    // Acumular tamanho em todos os diretórios ancestrais
                    let mut ancestral = e.path().parent();
                    while let Some(dir) = ancestral {
                        *tamanhos_dirs.entry(dir.to_path_buf()).or_insert(0) += tamanho;

                        if dir == raiz {
                            break;
                        }
                        ancestral = dir.parent();
                    }
                } else if e.file_type().is_dir() {
                    total_diretorios += 1;
                    tamanhos_dirs.entry(e.path().to_path_buf()).or_insert(0);
                }
            }
            Err(_) => {
                erros += 1;
            }
        }
    }

    let tamanho_total = tamanhos_dirs.get(&raiz.to_path_buf()).copied().unwrap_or(0);

    // Construir lista de itens
    let profundidade_raiz = raiz.components().count();

    let mut itens: Vec<ItemDisco> = tamanhos_dirs
        .into_iter()
        .filter_map(|(caminho, tamanho)| {
            let profundidade_abs = caminho.components().count();
            let profundidade_rel = profundidade_abs.saturating_sub(profundidade_raiz);

            if profundidade_rel > profundidade_max {
                return None;
            }

            // Pular a própria raiz para evitar duplicata no topo
            if caminho == raiz.to_path_buf() && profundidade_rel == 0 {
                return None;
            }

            Some(ItemDisco {
                caminho,
                tamanho,
                e_diretorio: true,
                profundidade: profundidade_rel,
            })
        })
        .collect();

    // Se não somente diretórios, adicionar também arquivos no nível de profundidade
    if !somente_diretorios {
        for entrada in WalkDir::new(raiz)
            .max_depth(profundidade_max)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            if entrada.file_type().is_file() {
                let profundidade_abs = entrada.path().components().count();
                let profundidade_rel = profundidade_abs.saturating_sub(profundidade_raiz);

                if profundidade_rel <= profundidade_max {
                    let tamanho = entrada.metadata().map(|m| m.len()).unwrap_or(0);
                    itens.push(ItemDisco {
                        caminho: entrada.into_path(),
                        tamanho,
                        e_diretorio: false,
                        profundidade: profundidade_rel,
                    });
                }
            }
        }
    }

    ResultadoVarredura {
        itens,
        tamanho_total,
        total_arquivos,
        total_diretorios,
        erros,
    }
}

O algoritmo funciona em duas passadas: primeiro percorre todos os arquivos e acumula seus tamanhos nos diretórios ancestrais (usando um HashMap), depois constrói a lista de itens respeitando a profundidade máxima.

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

// src/formatador.rs
use crate::scanner::ItemDisco;
use colored::*;
use std::path::Path;

/// Converte bytes para formato legível por humanos
pub fn formatar_tamanho(bytes: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = 1024 * KB;
    const GB: u64 = 1024 * MB;
    const TB: u64 = 1024 * GB;

    if bytes >= TB {
        format!("{:.2} TB", bytes as f64 / TB as f64)
    } else if bytes >= GB {
        format!("{:.2} GB", bytes as f64 / GB as f64)
    } else if bytes >= MB {
        format!("{:.2} MB", bytes as f64 / MB as f64)
    } else if bytes >= KB {
        format!("{:.2} KB", bytes as f64 / KB as f64)
    } else {
        format!("{} B", bytes)
    }
}

/// Gera uma barra visual proporcional ao tamanho
fn gerar_barra(proporcao: f64, largura: usize) -> String {
    let preenchido = (proporcao * largura as f64).round() as usize;
    let vazio = largura.saturating_sub(preenchido);
    format!("{}{}", "#".repeat(preenchido), ".".repeat(vazio))
}

/// Exibe os resultados da varredura no terminal
pub fn exibir_resultados(
    itens: &[ItemDisco],
    tamanho_total: u64,
    raiz: &str,
    mostrar_barra: bool,
) {
    // Cabeçalho
    println!(
        "\n{} {}\n",
        "Uso de disco:".bold().cyan(),
        raiz.bold()
    );

    if itens.is_empty() {
        println!("{}", "Nenhum item encontrado.".yellow());
        return;
    }

    // Largura da coluna de tamanho
    let largura_tamanho = 10;
    let largura_barra = 25;

    // Cabeçalho da tabela
    if mostrar_barra {
        println!(
            "{:>largura$}  {:>5}  {:<barra$}  {}",
            "Tamanho".bold(),
            "%".bold(),
            "Proporção".bold(),
            "Caminho".bold(),
            largura = largura_tamanho,
            barra = largura_barra
        );
        println!("{}", "-".repeat(largura_tamanho + largura_barra + 40));
    } else {
        println!(
            "{:>largura$}  {:>5}  {}",
            "Tamanho".bold(),
            "%".bold(),
            "Caminho".bold(),
            largura = largura_tamanho
        );
        println!("{}", "-".repeat(largura_tamanho + 40));
    }

    for item in itens {
        let tam = formatar_tamanho(item.tamanho);
        let proporcao = if tamanho_total > 0 {
            item.tamanho as f64 / tamanho_total as f64
        } else {
            0.0
        };
        let percentual = proporcao * 100.0;

        let caminho_display = formatar_caminho(&item.caminho, raiz);

        // Colorir baseado na proporção
        let tam_colorido = if percentual > 50.0 {
            tam.red().bold().to_string()
        } else if percentual > 20.0 {
            tam.yellow().to_string()
        } else {
            tam.to_string()
        };

        let icone = if item.e_diretorio { "DIR" } else { "   " };

        if mostrar_barra {
            let barra = gerar_barra(proporcao, largura_barra);
            let barra_colorida = if percentual > 50.0 {
                barra.red().to_string()
            } else if percentual > 20.0 {
                barra.yellow().to_string()
            } else {
                barra.green().to_string()
            };

            println!(
                "{:>largura$}  {:>4.1}%  {:<barra$}  {} {}",
                tam_colorido,
                percentual,
                barra_colorida,
                icone.dimmed(),
                caminho_display,
                largura = largura_tamanho,
                barra = largura_barra
            );
        } else {
            println!(
                "{:>largura$}  {:>4.1}%  {} {}",
                tam_colorido,
                percentual,
                icone.dimmed(),
                caminho_display,
                largura = largura_tamanho
            );
        }
    }
}

/// Formata o caminho relativo à raiz para exibição
fn formatar_caminho(caminho: &Path, raiz: &str) -> String {
    let raiz_path = Path::new(raiz);
    caminho
        .strip_prefix(raiz_path)
        .map(|p| p.display().to_string())
        .unwrap_or_else(|_| caminho.display().to_string())
}

/// Exibe o resumo da varredura
pub fn exibir_resumo(
    tamanho_total: u64,
    total_arquivos: usize,
    total_diretorios: usize,
    erros: usize,
) {
    println!("\n{}", "Resumo".bold().cyan());
    println!("  Tamanho total:  {}", formatar_tamanho(tamanho_total).bold());
    println!("  Arquivos:       {}", total_arquivos);
    println!("  Diretórios:     {}", total_diretorios);
    if erros > 0 {
        println!("  Erros:          {} (permissão negada ou inacessível)", erros.to_string().red());
    }
}

A função formatar_tamanho converte bytes brutos para unidades legíveis (KB, MB, GB, TB). A barra visual usa caracteres # e . para representar a proporção de cada item em relação ao total.

Passo 4: Integrando no main.rs

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

use clap::Parser;
use cli::Cli;
use std::path::Path;

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

    // Verificar se o diretório existe
    let caminho = Path::new(&cli.diretorio);
    if !caminho.exists() {
        eprintln!(
            "Erro: '{}' não existe.",
            cli.diretorio
        );
        std::process::exit(1);
    }
    if !caminho.is_dir() {
        eprintln!(
            "Erro: '{}' não é um diretório.",
            cli.diretorio
        );
        std::process::exit(1);
    }

    // Executar a varredura
    let resultado = scanner::varrer_diretorio(
        &cli.diretorio,
        cli.profundidade,
        cli.somente_diretorios,
    );

    // Filtrar e ordenar itens
    let mut itens = resultado.itens;

    // Aplicar filtro de tamanho mínimo
    if cli.minimo > 0 {
        itens.retain(|item| item.tamanho >= cli.minimo);
    }

    // Ordenar por tamanho (maiores primeiro)
    if cli.ordenar {
        itens.sort_by(|a, b| b.tamanho.cmp(&a.tamanho));
    }

    // Limitar número de resultados
    itens.truncate(cli.limite);

    // Exibir resultados
    formatador::exibir_resultados(
        &itens,
        resultado.tamanho_total,
        &cli.diretorio,
        cli.barra,
    );

    formatador::exibir_resumo(
        resultado.tamanho_total,
        resultado.total_arquivos,
        resultado.total_diretorios,
        resultado.erros,
    );
}

Como Executar

cargo build --release

Exemplos de uso:

# Analisar o diretório atual
./target/release/espaco

# Analisar um diretório específico
./target/release/espaco /home/usuario/projetos

# Limitar profundidade e quantidade de resultados
./target/release/espaco /var/log -p 2 -n 10

# Exibir somente diretórios
./target/release/espaco ~/Documentos -d

# Filtrar itens menores que 1MB
./target/release/espaco /home -m 1048576

# Saída sem barra visual
./target/release/espaco . --barra false

# Exemplo de saída:
#
# Uso de disco: /home/usuario/projetos
#
#   Tamanho      %  Proporção                  Caminho
# ------------------------------------------------------------------
#   245.30 MB  45.2%  ###########..............  DIR target/
#    89.50 MB  16.5%  ####.....................  DIR node_modules/
#    34.20 MB   6.3%  ##.......................  DIR .git/
#    12.80 MB   2.4%  #........................  DIR src/
#     5.60 MB   1.0%  .........................  DIR docs/
#
# Resumo
#   Tamanho total:  542.10 MB
#   Arquivos:       12453
#   Diretórios:     876

Desafios para Expandir

  1. Modo interativo com navegação: Use a crate crossterm ou tui para criar uma interface interativa que permita navegar entre diretórios, expandir/recolher subpastas e deletar itens.

  2. Detecção de arquivos duplicados: Calcule hashes (SHA-256) dos arquivos para encontrar duplicatas e mostrar quanto espaço poderia ser economizado removendo-as.

  3. Exportação para JSON/CSV: Adicione uma flag --formato json ou --formato csv que exporte os resultados em formato estruturado para integração com outras ferramentas.

  4. Cache de varredura: Salve o resultado da varredura em um cache local e na próxima execução, compare com o estado anterior para mostrar o que cresceu ou diminuiu desde a última análise.

  5. Filtro por tipo de arquivo: Adicione opções como --tipo imagens ou --extensao mp4 para calcular o espaço ocupado por categorias específicas de arquivos.

Veja Também

  • Módulo Path — trabalhando com caminhos de arquivos e diretórios
  • Módulo fs — operações de sistema de arquivos em Rust
  • HashMap — acumulação de dados por chave
  • Lendo Arquivos — padrões de leitura de arquivos
  • Rust para CLIs — boas práticas para ferramentas de linha de comando