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 exemploorder_service.version: versão seguindo Semantic Versioning.edition: a edição da linguagem. A edição 2024 está disponível desde Rust 1.85, ecargo newa 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 featurederivedoserde. Sem esta feature, o código relata um erro de macro não encontrada.serde_json::to_stringconverte a struct em uma string JSON.serde_json::from_strfaz o inverso. O tipo de destino é inferido a partir da anotaçãoOrder.
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óriocrates/.[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);
}
}
pubnas structs e fields permite que o crateapias acesse.u64::from(line.quantity)converteu32parau64sem perder dados.#[cfg(test)]garante que o módulo de testes é compilado apenas ao executarcargo 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/fulle o crate B só precise detokio/rt. Ao compilar juntos no workspace, B também recebefull. O código pode funcionar no workspace mas falhar quando você compila B isoladamente. Executecargo 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 defeatures = ["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 semversion. Para publicar, declare ambos:{ path = "../domain", version = "0.1.0" }. -
Workspaces aninhados. Um crate pertence a apenas um workspace. Se você
cargo newdentro de um subdiretório de outro workspace, Cargo reportará um erro ou adicionará automaticamente o crate amembers. 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 usarcargo checkpara um feedback rápido. - Adicionar dependências com
cargo add, entender como version requirements eCargo.lockdeterminam as versões reais usadas, e separardev-dependenciesdas dependências principais. - Agrupar múltiplos crates em um workspace, declarar versões compartilhadas em
[workspace.dependencies]e herdar comworkspace = 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.