Bem-vindo à aula 47 do nosso curso de Rust! Hoje vamos mergulhar em um dos aspectos mais importantes para projetos reais: a organização do código em crates e workspaces. Se você já trabalhou com projetos maiores, sabe que manter tudo em um único arquivo ou em um único crate rapidamente se torna caótico. Rust oferece um sistema de módulos e, acima disso, um sistema de crates que permite dividir seu código em unidades compiláveis e reutilizáveis. Além disso, os workspaces permitem gerenciar múltiplos crates relacionados em um único repositório, facilitando a manutenção e o compartilhamento de dependências.

Nesta aula, vamos entender a diferença fundamental entre crates binários e de biblioteca, como criar e configurar um workspace, e como organizar projetos grandes de maneira eficiente. Ao final, você terá as ferramentas necessárias para estruturar seus próprios projetos Rust de forma profissional, prontos para escalar e serem mantidos com facilidade.

Crate binário vs biblioteca

Um crate é a menor unidade de compilação no Rust. Ele pode ser de dois tipos: binário (executável) ou biblioteca (lib). Um crate binário é aquele que gera um executável, ou seja, um programa que pode ser executado diretamente. Já um crate de biblioteca é uma coleção de código que pode ser usada por outros crates, mas não é executável por si só.

Quando você inicia um novo projeto com cargo new, o Cargo cria automaticamente um crate binário com um arquivo src/main.rs. Se você adicionar --lib, ele cria um crate de biblioteca com src/lib.rs. A principal diferença está no ponto de entrada: binários têm a função main, enquanto bibliotecas não têm e expõem funcionalidades através de itens públicos (usando pub).

Na prática, você pode ter um crate que é tanto biblioteca quanto binário, se você incluir tanto src/lib.rs quanto src/main.rs. Nesse caso, o binário pode usar a biblioteca interna. Isso é comum para criar ferramentas de linha de comando que também oferecem uma API para outros projetos.

// src/lib.rs
pub fn saudacao(nome: &str) -> String {
    format!("Olá, {}!", nome)
}

// src/main.rs
fn main() {
    let msg = saudacao("Mundo");
    println!("{}", msg);
}

Para declarar se um crate é biblioteca ou binário, você usa a seção [lib] ou [[bin]] no Cargo.toml. Por padrão, o Cargo infere a partir dos arquivos presentes. Você pode ter múltiplos binários em um único crate, especificando-os na seção [[bin]].

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

[lib]
name = "meu_projeto"
path = "src/lib.rs"

[[bin]]
name = "meu_binario"
path = "src/main.rs"

[[bin]]
name = "outro_binario"
path = "src/bin/outro.rs"

Escolher entre binário e biblioteca é uma decisão estratégica. Se você quer apenas um executável, um crate binário é suficiente. Se você quer compartilhar código entre projetos, uma biblioteca é a escolha certa. Muitos projetos fazem ambos: um binário para a interface e uma biblioteca para a lógica central.

Workspaces

Um workspace é um conjunto de crates que compartilham o mesmo Cargo.lock e o mesmo diretório de saída (target). Isso significa que as dependências são resolvidas de forma unificada, e todos os crates do workspace são compilados juntos. Workspaces são ideais para projetos que consistem em múltiplos crates relacionados, como uma aplicação com vários módulos ou uma coleção de bibliotecas.

Para criar um workspace, você precisa de um Cargo.toml na raiz que defina os membros. Por exemplo, suponha que você queira um projeto com dois crates: um binário e uma biblioteca. A estrutura seria:

meu_workspace/
  Cargo.toml
  meu_binario/
    Cargo.toml
    src/
      main.rs
  minha_lib/
    Cargo.toml
    src/
      lib.rs

O Cargo.toml na raiz do workspace não é um crate em si, mas define os membros. Ele pode ter um pacote opcional, mas é comum que não tenha.

[workspace]
members = ["meu_binario", "minha_lib"]
resolver = "2"

Dentro de cada crate membro, você pode declarar dependências de outros crates do workspace usando path no Cargo.toml do crate. Por exemplo, o binário pode depender da biblioteca:

# meu_binario/Cargo.toml
[package]
name = "meu_binario"
version = "0.1.0"
edition = "2021"

[dependencies]
minha_lib = { path = "../minha_lib" }

Com isso, você pode usar as funções da biblioteca no binário. O Cargo garante que as dependências sejam compiladas na ordem correta e que o Cargo.lock seja compartilhado, evitando conflitos de versões.

Além disso, você pode executar comandos em todo o workspace usando cargo build, cargo test, etc., que serão aplicados a todos os membros. Isso simplifica o desenvolvimento e a integração contínua.

Organizando projetos grandes

Projetos grandes exigem uma organização cuidadosa. O Rust oferece várias ferramentas para isso: módulos, crates e workspaces. A combinação dessas ferramentas permite uma separação clara de responsabilidades e facilita a manutenção.

Uma estratégia comum é dividir o projeto em crates menores, cada um com uma responsabilidade específica. Por exemplo, em um servidor web, você pode ter um crate para o servidor HTTP, outro para a lógica de negócios, outro para acesso a banco de dados, e assim por diante. Esses crates podem ser organizados em um workspace para simplificar o desenvolvimento.

Dentro de cada crate, você pode usar módulos para organizar o código em arquivos e pastas. Os módulos permitem que você agrupe funcionalidades relacionadas e controle a visibilidade. Combinando módulos e crates, você pode criar uma hierarquia clara:

meu_projeto/
  Cargo.toml
  src/
    main.rs
    lib.rs
    modulo1/
      mod.rs
      submodulo.rs
    modulo2.rs

No lib.rs, você declara os módulos públicos:

pub mod modulo1;
pub mod modulo2;

E nos arquivos de módulo, você define as funções e estruturas. Essa organização facilita a navegação e o teste, pois cada módulo pode ser testado isoladamente.

Outra prática é usar feature flags para ativar ou desativar partes do código em tempo de compilação. Isso é útil para criar versões diferentes do mesmo crate, como uma versão leve e uma completa.

Ao trabalhar com workspaces, é importante manter a consistência entre as versões dos crates. O Cargo.lock garante que todos usem as mesmas versões das dependências, mas você deve ter cuidado ao publicar crates do workspace no crates.io, pois cada crate precisa ter sua própria versão e ser publicado separadamente.

Boas práticas e observações finais

Organizar um projeto Rust de forma eficiente é uma habilidade que se desenvolve com a prática. Algumas dicas:

  • Comece com um único crate e só divida em workspace quando o projeto crescer e você sentir necessidade de separar responsabilidades.
  • Mantenha a hierarquia de módulos rasa e significativa; evite aninhamentos excessivos.
  • Use pub use para reexportar itens e simplificar a API pública.
  • Documente os crates com cargo doc para gerar documentação HTML navegável.
  • Configure o rustfmt e o clippy para manter o estilo consistente e evitar erros comuns.
  • Em workspaces, considere usar o cargo metadata para inspecionar a estrutura do workspace em scripts.

Com essas ferramentas, você estará preparado para construir projetos Rust de qualquer tamanho, mantendo o código limpo, modular e fácil de manter.

Referências

Exercícios

  1. Crie um novo projeto Cargo chamado meu_crate com suporte a biblioteca e binário. Escreva uma função dobro na biblioteca que recebe um inteiro e retorna o dobro. No binário, chame a função e imprima o resultado para alguns valores.

✓ Resposta:
cargo new meu_crate --lib
cd meu_crate
# adicionar manualmente src/main.rs e editar Cargo.toml

Em src/lib.rs:

pub fn dobro(x: i32) -> i32 {
    x * 2
}

Em src/main.rs:

use meu_crate::dobro;

fn main() {
    for i in 1..=5 {
        println!("{} * 2 = {}", i, dobro(i));
    }
}

Em Cargo.toml, adicione a seção [lib] e [[bin]] conforme explicado.

  • Crie um workspace chamado meu_workspace com dois crates: calculadora (biblioteca) e main_cli (binário). A biblioteca deve ter funções de soma, subtração, multiplicação e divisão. O binário deve usar a biblioteca para executar operações simples fornecidas como argumentos de linha de comando.
  • ✓ Resposta:
    cargo new meu_workspace --workspace
    cd meu_workspace
    cargo new calculadora --lib
    cargo new main_cli

    Edite o Cargo.toml raiz para incluir os membros:

    [workspace]
    members = ["calculadora", "main_cli"]

    Em calculadora/src/lib.rs:

    pub fn soma(a: f64, b: f64) -> f64 { a + b }
    pub fn subtrai(a: f64, b: f64) -> f64 { a - b }
    pub fn multiplica(a: f64, b: f64) -> f64 { a * b }
    pub fn divide(a: f64, b: f64) -> Result<f64, String> {
        if b == 0.0 { Err("Divisão por zero".to_string()) } else { Ok(a / b) }
    }

    Em main_cli/Cargo.toml, adicione a dependência:

    [dependencies]
    calculadora = { path = "../calculadora" }

    Em main_cli/src/main.rs:

    use std::env;
    use calculadora::*;
    
    fn main() {
        let args: Vec<String> = env::args().collect();
        if args.len() != 4 {
            eprintln!("Uso: {} operação a b", args[0]);
            std::process::exit(1);
        }
        let op = &args[1];
        let a: f64 = args[2].parse().expect("Número inválido");
        let b: f64 = args[3].parse().expect("Número inválido");
        match op.as_str() {
            "soma" => println!("{}", soma(a, b)),
            "sub" => println!("{}", subtrai(a, b)),
            "mul" => println!("{}", multiplica(a, b)),
            "div" => match divide(a, b) {
                Ok(r) => println!("{}", r),
                Err(e) => eprintln!("{}", e),
            },
            _ => eprintln!("Operação desconhecida"),
        }
    }
  • Explique a diferença entre um crate binário e uma biblioteca. Dê um exemplo de quando você usaria cada um.
  • ✓ Resposta: Um crate binário é um programa executável com uma função main, destinado a ser executado diretamente. Uma biblioteca é um conjunto de código reutilizável, sem ponto de entrada, que pode ser usado por outros crates. Use um binário para ferramentas de linha de comando, aplicativos, servidores; use uma biblioteca para lógica de negócios, utilidades, APIs que serão incorporadas em outros projetos. Por exemplo, uma biblioteca de manipulação de imagens seria uma lib, enquanto um aplicativo de edição de imagens seria um binário que usa essa lib.
  • Crie um workspace com dois crates: utils (biblioteca) e app (binário). A biblioteca deve conter uma função soma e exibe_soma que imprime o resultado. O binário deve chamar exibe_soma com dois números. Compile e execute o projeto.
  • ✓ Resposta: Crie a estrutura com cargo new utils --lib e cargo new app. Adicione utils como dependência do app. Em utils/src/lib.rs:

    pub fn soma(a: i32, b: i32) -> i32 { a + b }
    pub fn exibe_soma(a: i32, b: i32) {
        println!("A soma de {} e {} é {}", a, b, soma(a, b));
    }

    Em app/src/main.rs:

    use utils::exibe_soma;
    
    fn main() {
        exibe_soma(5, 7);
    }

    Depois, cargo build e cargo run -p app.

  • Pesquise sobre o comando cargo test em um workspace. Como você executaria testes de todos os crates? E apenas de um crate? Escreva um teste simples em um dos crates e execute.
  • ✓ Resposta: Para executar testes de todos os crates, basta rodar cargo test na raiz do workspace. Para testar apenas um crate, use cargo test -p nome_do_crate. Exemplo: adicione um teste em utils:

    #[cfg(test)]
    mod tests {
        use super::*;
        #[test]
        fn test_soma() {
            assert_eq!(soma(2, 3), 5);
        }
    }

    Execute cargo test -p utils na raiz do workspace.