Cargo: crear proyectos, gestionar dependencias y organizar workspaces
1. Descripción general
Cargo es la herramienta de compilación y gestor de paquetes oficial de Rust. Crea proyectos, descarga dependencias, compila, ejecuta tests y gestiona múltiples crates en un mismo repositorio.
Este artículo cubre tres tareas que realizas cada día con Cargo. Primera, crear proyectos binarios y librerías. Segunda, agregar y configurar dependencias. Tercera, organizar un workspace con múltiples crates que comparten configuración. Después de leer, podrás construir un proyecto Rust multimodular sin necesidad de copiar versiones de dependencias por todas partes.
2. Crear un nuevo proyecto
2.1. Binarios y librerías
El comando cargo new crea un crate nuevo. Por defecto es un crate binario, es decir, un programa con una función main:
cargo new order-service
cd order-service
La estructura de directorios generada:
order-service/
├── Cargo.toml
├── .gitignore
└── src/
└── main.rs
Si quieres escribir una librería para que otros crates la usen, agregas la bandera --lib:
cargo new pricing --lib
Entonces Cargo crea src/lib.rs en lugar de src/main.rs. Un crate puede tener ambos archivos. Cuando es así, main.rs llama a la lógica en lib.rs a través del nombre del crate.
2.2. Archivo Cargo.toml
Cargo.toml es el manifiesto del crate:
[package]
name = "order-service"
version = "0.1.0"
edition = "2024"
[dependencies]
name: nombre del crate. Los guiones-en el nombre se convierten en_cuando los usas en código, por ejemploorder_service.version: versión según Semantic Versioning.edition: versión del lenguaje. Edition 2024 existe desde Rust 1.85, ycargo newlo usa como predeterminado en la toolchain actual.[dependencies]: lista de crates externos, actualmente vacía.
2.3. Comandos de compilación básicos
cargo check # revisar errores de compilación, no genera ejecutables
cargo build # compila la versión debug en target/debug
cargo run # compila y ejecuta
cargo build --release # compila optimizado en target/release
Mientras escribes código, debes usar cargo check. Este comando es mucho más rápido que cargo build porque omite la generación de código de máquina. Solo ejecuta --release cuando midas rendimiento o compiles para producción. La versión debug puede ser decenas de veces más lenta que la versión release.
3. Agregar dependencias
3.1. Usando cargo add
Supongamos que necesitas serializar una Order a JSON. Dos crates populares para esto son serde y serde_json:
cargo add serde --features derive
cargo add serde_json
Cargo modifica automáticamente Cargo.toml:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Puedes editar este archivo manualmente. cargo add solo te ayuda a evitar errores de tipeo y a elegir automáticamente la versión más nueva compatible.
3.2. ¿Qué significa version requirement?
La cadena "1.0" no significa exactamente la versión 1.0.0. Cargo la interpreta como ^1.0, es decir, cualquier versión >= 1.0.0 y < 2.0.0. Otras formas de escritura:
[dependencies]
regex = "1.10" # >= 1.10.0, < 2.0.0
chrono = "=0.4.38" # exactamente 0.4.38
rand = "~0.8.5" # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9" # rango personalizado
Con la versión 0.x, Cargo considera el segundo número como la parte “major”. Por lo tanto, "0.4" solo permite 0.4.* y no sube a 0.5.
La versión que realmente se usa está en Cargo.lock. Cargo escribe este archivo en la primera compilación. Las compilaciones posteriores usarán exactamente esas versiones hasta que ejecutes:
cargo update # actualizar todas las dependencias dentro del rango permitido
cargo update -p serde # solo actualizar serde
3.3. Usar dependencias en código
Edita 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("serializar Order");
println!("{json}");
let parsed: Order = serde_json::from_str(&json).expect("deserializar Order");
println!("{parsed:?}");
}
#[derive(Serialize, Deserialize)]solo está disponible cuando activas la featurederivedeserde. Sin esta feature, el código reporta que no encuentra la macro.serde_json::to_stringconvierte el struct a una cadena JSON.serde_json::from_strhace lo opuesto. El tipo destino se deduce de la anotaciónOrder.
Ejecuta cargo run:
{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }
3.4. Otros tipos de dependencias
Además de [dependencies], Cargo tiene otros dos grupos comúnmente usados:
[dev-dependencies]
pretty_assertions = "1.4" # solo se usa cuando ejecutas tests, ejemplos, benchmarks
[build-dependencies]
cc = "1.0" # solo se usa para build.rs
Los crates en [dev-dependencies] no se incluyen en el binario final y no afectan a los usuarios de tu librería. Coloca las librerías que solo sirven para testing aquí.
También puedes apuntar una dependencia a un directorio local o a Git:
[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }
4. Organizar workspaces
Conforme tu proyecto crece, querrás dividirlo en múltiples crates. Por ejemplo: un crate domain con la lógica de negocio, un crate api como el programa ejecutable. Un workspace permite que estos crates compartan Cargo.lock, el directorio target/ y las versiones de dependencias.
4.1. Crear un workspace raíz
mkdir shop && cd shop
Crea 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 archivo no tiene
[package], por lo que es un virtual manifest. Solo se usa para agrupar los crates subordinados. resolver = "3"debe declararse explícitamente en un virtual manifest. Sin[package], Cargo no puede deducir el resolver de la edition y te advertirá si omites esta línea.members = ["crates/*"]agrupa todos los crates que están en el directoriocrates/.[workspace.package]y[workspace.dependencies]son valores compartidos que los crates subordinados heredan.
Crea dos crates subordinados:
cargo new crates/domain --lib
cargo new crates/api
La estructura resultante:
shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
├── domain/
│ ├── Cargo.toml
│ └── src/lib.rs
└── api/
├── Cargo.toml
└── src/main.rs
4.2. Heredar configuración del 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 obtiene la versión y características de [workspace.dependencies]. Cuando necesites actualizar serde, solo modificas un lugar en el archivo raíz.
Un crate subordinado aún puede activar características adicionales propias:
[dependencies]
serde = { workspace = true, features = ["rc"] }
Las características aquí se suman a las declaradas en el workspace, no las reemplazan.
4.3. Código en el 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);
}
}
puben el struct y los campos permite que el crateapiacceda a ellos.u64::from(line.quantity)convierteu32au64sin pérdida de datos.#[cfg(test)]asegura que el módulo test solo se compila cuando ejecutascargo test.
Ejecuta tests solo para el 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
Ejecutar cargo test en el directorio raíz ejecutará los tests de todos los crates en el workspace.
4.4. Usar domain desde 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("serializar"));
}
Desde el directorio raíz:
cargo run -p api
Total: 60000
{
"id": 7,
"customer": "Dung",
"lines": [
{
"product": "Tea",
"unit_price_cents": 20000,
"quantity": 3
}
]
}
El crate api no declara serde, pero aun así puede serializar Order. La razón es que el trait Serialize ya está implementado dentro del crate domain. api solo necesita serde_json para llamar a la función de conversión.
4.5. Comandos útiles en workspace
cargo build --workspace # compila todos los crates
cargo check -p api # solo revisa el crate api y sus dependencias
cargo tree -p api # muestra el árbol de dependencias
cargo tree -d # lista los crates con múltiples versiones
cargo tree -d es muy útil cuando el binario es anormalmente grande. Los resultados suelen mostrar que hay dos versiones del mismo crate siendo incluidas.
5. Errores comunes
Nota: Las features en Cargo se unifican (feature unification). Supongamos que el crate A activa
tokio/fully el crate B solo necesitatokio/rt. Cuando compilas juntos en un workspace, B también recibefull. El código que funciona en el workspace puede fallar cuando compilas B por separado. Ejecutacargo check -p <nombre-del-crate>para cada crate en CI.
- Olvidar activar feature. Errores como “cannot find derive macro
Serialize” casi siempre son por falta defeatures = ["derive"]. - No hacer commit de
Cargo.lock. La guía actual de Cargo recomienda hacer commit de este archivo tanto para binarios como para librerías. Los builds en CI pueden así reproducirse entre ejecuciones. - Dependencias de path al publicar.
domain = { path = "../domain" }no se puede publicar en crates.io sinversion. Para publicar, declara ambos:{ path = "../domain", version = "0.1.0" }. - Workspaces anidados. Un crate pertenece solo a un workspace. Si ejecutas
cargo newdentro de un subdirectorio de otro workspace, Cargo reportará un error o agregará automáticamente el crate amembers. Verifica el archivo raíz después de crear. - Usar excesivamente workspace. Para un programa pequeño, solo necesitas dividir módulos dentro de un crate (
mod order;). Separar crates aumenta el número de manifiestos y el tiempo de configuración. Usa workspace cuando las partes tengan límites claros, necesiten compilarse o testearse por separado, o requieran reutilización.
6. Conclusión
Este artículo ha cubierto tres tareas fundamentales con Cargo:
- Crear proyectos binarios o librerías con
cargo new, y usarcargo checkpara obtener retroalimentación rápida. - Agregar dependencias con
cargo add, entender cómo los version requirements yCargo.lockdeterminan las versiones reales, y separardev-dependenciesde las dependencias principales. - Agrupar múltiples crates en un workspace, declarar versiones compartidas en
[workspace.dependencies]e heredarlas conworkspace = true.
Conforme tu proyecto crece, debes mantener un principio simple: todas las versiones de dependencias están en el archivo raíz, cada crate solo declara lo que realmente usa. Esta aproximación hace que actualizar y controlar el tamaño del binario sea mucho más fácil.