Cargo: criando projetos, gerenciando dependências e organizando workspaces

1. Visão geral

Cargo é a ferramenta de build e gerenciador de pacotes oficial do Rust. Ele cria projetos, baixa dependências, compila, executa testes e gerencia múltiplos crates no mesmo repositório.

Este artigo passa por três tarefas que você faz diariamente com Cargo. Primeiro, criar projetos binários e bibliotecas. Segundo, adicionar e configurar dependências. Terceiro, organizar um workspace com múltiplos crates compartilhando a configuração. Ao final, você será capaz de estruturar um projeto Rust com vários módulos sem ter que duplicar versões de dependências em diversos locais.

2. Criando um novo projeto

2.1. Binário e biblioteca

O comando cargo new cria um novo crate. Por padrão, é um crate binário, ou seja, um programa com a função main:

cargo new order-service
cd order-service

A estrutura de diretórios criada:

order-service/
├── Cargo.toml
├── .gitignore
└── src/
    └── main.rs

Se você quer escrever uma biblioteca para que outros crates usem, adicione a flag --lib:

cargo new pricing --lib

Neste caso, Cargo cria src/lib.rs em vez de src/main.rs. Um crate pode ter ambos os arquivos. Nesse caso, main.rs chama a lógica em lib.rs através do nome do crate.

2.2. O arquivo Cargo.toml

Cargo.toml é o manifesto do crate:

[package]
name = "order-service"
version = "0.1.0"
edition = "2024"

[dependencies]
  • name: o nome do crate. Hífens no nome se tornam _ quando você usa no código, por exemplo order_service.
  • version: versão seguindo Semantic Versioning.
  • edition: a edição da linguagem. A edição 2024 está disponível desde Rust 1.85, e cargo new a usa como padrão na toolchain atual.
  • [dependencies]: lista de crates externos, atualmente vazia.

2.3. Comandos básicos de compilação

cargo check        # verifica erros de compilação, sem gerar executável
cargo build        # compila a versão debug para target/debug
cargo run          # compila e executa
cargo build --release   # compila versão otimizada para target/release

Enquanto está escrevendo código, você deve usar cargo check. Este comando é muito mais rápido que cargo build porque pula a geração de código de máquina. Use --release apenas ao medir desempenho ou compilar para produção. A versão debug pode ser dezenas de vezes mais lenta que a versão release.

3. Adicionando dependências

3.1. Usando cargo add

Suponha que você precisa serializar uma Order para JSON. Dois crates populares para isso são serde e serde_json:

cargo add serde --features derive
cargo add serde_json

Cargo modifica automaticamente Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Você pode editar este arquivo manualmente. cargo add apenas ajuda a evitar erros de digitação e escolher a versão mais recente compatível automaticamente.

3.2. O que significa um requisito de versão?

A string "1.0" não significa exatamente a versão 1.0.0. Cargo a interpreta como ^1.0, ou seja, qualquer versão >= 1.0.0 e < 2.0.0. Aqui estão outras formas de especificar:

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # exatamente 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # intervalo customizado

Para versões 0.x, Cargo considera o segundo número como a parte “major”. Portanto "0.4" permite apenas 0.4.* e não permite atualizar para 0.5.

As versões reais usadas estão em Cargo.lock. Cargo escreve este arquivo na primeira compilação. Compilações subsequentes usarão exatamente essas versões até você executar:

cargo update              # atualiza todas as dependências dentro do intervalo permitido
cargo update -p serde     # atualiza apenas serde

3.3. Usando dependências no código

Edite src/main.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u64,
    customer: String,
    total_cents: u64,
}

fn main() {
    let order = Order {
        id: 42,
        customer: "An".to_string(),
        total_cents: 150_000,
    };

    let json = serde_json::to_string(&order).expect("serialize Order");
    println!("{json}");

    let parsed: Order = serde_json::from_str(&json).expect("deserialize Order");
    println!("{parsed:?}");
}
  • #[derive(Serialize, Deserialize)] só funciona quando você ativa a feature derive do serde. Sem esta feature, o código relata um erro de macro não encontrada.
  • serde_json::to_string converte a struct em uma string JSON.
  • serde_json::from_str faz o inverso. O tipo de destino é inferido a partir da anotação Order.

Execute cargo run:

{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }

3.4. Outros tipos de dependências

Além de [dependencies], Cargo tem dois outros grupos comumente usados:

[dev-dependencies]
pretty_assertions = "1.4"   # apenas para testes, exemplos e benchmarks

[build-dependencies]
cc = "1.0"                  # apenas para build.rs

Crates em [dev-dependencies] não são inclusos no binário final e não afetam os usuários da sua biblioteca. Coloque as bibliotecas que servem apenas para testes aqui.

Você também pode apontar uma dependência para um diretório local ou Git:

[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }

4. Organizando workspaces

Conforme seu projeto crescer, você vai querer dividi-lo em múltiplos crates. Por exemplo: um crate domain contendo a lógica de negócio, um crate api como o programa em execução. O workspace permite que esses crates compartilhem Cargo.lock, o diretório target/ e as versões de dependências.

4.1. Criando um workspace raiz

mkdir shop && cd shop

Crie shop/Cargo.toml:

[workspace]
resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
  • Este arquivo não tem [package], então é um manifesto virtual. Ele é usado apenas para agrupar os subcrates.
  • resolver = "3" deve ser declarado explicitamente em um manifesto virtual. Sem [package], Cargo não pode inferir o resolver a partir da edition e avisará se você omitir esta linha.
  • members = ["crates/*"] agrupa todos os crates localizados no diretório crates/.
  • [workspace.package] e [workspace.dependencies] são valores compartilhados que os subcrates herdam.

Crie dois subcrates:

cargo new crates/domain --lib
cargo new crates/api

A estrutura resultante:

shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
    ├── domain/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── api/
        ├── Cargo.toml
        └── src/main.rs

4.2. Herdando configuração do workspace

crates/domain/Cargo.toml:

[package]
name = "domain"
version.workspace = true
edition.workspace = true

[dependencies]
serde.workspace = true

crates/api/Cargo.toml:

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true

serde.workspace = true significa que este crate pega a versão e features de [workspace.dependencies]. Quando precisar atualizar serde, você edita apenas em um lugar no arquivo raiz.

Os subcrates ainda podem habilitar features adicionais por conta própria:

[dependencies]
serde = { workspace = true, features = ["rc"] }

As features aqui são adicionadas às features declaradas no workspace, não as substituem.

4.3. Código no crate domain

crates/domain/src/lib.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
    pub product: String,
    pub unit_price_cents: u64,
    pub quantity: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub lines: Vec<OrderLine>,
}

impl Order {
    pub fn total_cents(&self) -> u64 {
        self.lines
            .iter()
            .map(|line| line.unit_price_cents * u64::from(line.quantity))
            .sum()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(product: &str, price: u64, qty: u32) -> OrderLine {
        OrderLine {
            product: product.to_string(),
            unit_price_cents: price,
            quantity: qty,
        }
    }

    #[test]
    fn total_sums_all_lines() {
        let order = Order {
            id: 1,
            customer: "Binh".to_string(),
            lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
        };

        assert_eq!(order.total_cents(), 95_000);
    }

    #[test]
    fn empty_order_has_zero_total() {
        let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };

        assert_eq!(order.total_cents(), 0);
    }
}
  • pub nas structs e fields permite que o crate api as acesse.
  • u64::from(line.quantity) converte u32 para u64 sem perder dados.
  • #[cfg(test)] garante que o módulo de testes é compilado apenas ao executar cargo test.

Execute os testes apenas para o crate domain:

cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

Executar cargo test no diretório raiz executará os testes de todos os crates no workspace.

4.4. Usando domain a partir de api

crates/api/src/main.rs:

use domain::{Order, OrderLine};

fn main() {
    let order = Order {
        id: 7,
        customer: "Dung".to_string(),
        lines: vec![OrderLine {
            product: "Tea".to_string(),
            unit_price_cents: 20_000,
            quantity: 3,
        }],
    };

    println!("Total: {}", order.total_cents());
    println!("{}", serde_json::to_string_pretty(&order).expect("serialize"));
}

Do diretório raiz:

cargo run -p api
Total: 60000
{
  "id": 7,
  "customer": "Dung",
  "lines": [
    {
      "product": "Tea",
      "unit_price_cents": 20000,
      "quantity": 3
    }
  ]
}

O crate api não declara serde, mas ainda consegue serializar Order. A razão é que a trait Serialize já foi implementada dentro do crate domain. api precisa apenas de serde_json para chamar a função de conversão.

4.5. Comandos úteis em workspaces

cargo build --workspace     # compila todos os crates
cargo check -p api          # verifica apenas o crate api e suas dependências
cargo tree -p api           # mostra a árvore de dependências
cargo tree -d               # lista crates com múltiplas versões

cargo tree -d é muito útil quando o binário fica anormalmente grande. O resultado geralmente mostra que duas versões do mesmo crate foram puxadas.

5. Erros comuns

Nota: Features no Cargo são unificadas. Suponha que o crate A habilite tokio/full e o crate B só precise de tokio/rt. Ao compilar juntos no workspace, B também recebe full. O código pode funcionar no workspace mas falhar quando você compila B isoladamente. Execute cargo check -p <nome-do-crate> para cada crate em CI.

  • Esquecer de habilitar uma feature. Erros como “cannot find derive macro Serialize” quase sempre são causados pela falta de features = ["derive"].

  • Não fazer commit de Cargo.lock. As diretrizes atuais do Cargo recomendam fazer commit deste arquivo para binários e bibliotecas. Builds no CI conseguem se repetir graças a isso.

  • Path dependency ao publicar. domain = { path = "../domain" } não pode ser publicado em crates.io sem version. Para publicar, declare ambos: { path = "../domain", version = "0.1.0" }.

  • Workspaces aninhados. Um crate pertence a apenas um workspace. Se você cargo new dentro de um subdiretório de outro workspace, Cargo reportará um erro ou adicionará automaticamente o crate a members. Verifique novamente o arquivo raiz após criar.

  • Usar workspace em excesso. Para um programa pequeno, você só precisa dividir módulos dentro de um crate (mod order;). Separar em crates aumenta o número de manifestos e o tempo de configuração. Use workspace quando as partes têm limites claros, precisam de compilação ou testes separados, ou precisam ser reutilizadas.

6. Conclusão

Este artigo passou por três tarefas essenciais com Cargo:

  • Criar projetos binários ou bibliotecas com cargo new, e usar cargo check para um feedback rápido.
  • Adicionar dependências com cargo add, entender como version requirements e Cargo.lock determinam as versões reais usadas, e separar dev-dependencies das dependências principais.
  • Agrupar múltiplos crates em um workspace, declarar versões compartilhadas em [workspace.dependencies] e herdar com workspace = true.

Conforme seu projeto cresce, mantenha um princípio simples: todas as versões de dependências ficam no arquivo raiz, cada crate declara apenas o que realmente usa. Isso torna a atualização e o controle do tamanho do binário muito mais fácil.

rustcargodependencyworkspacebuild-tool