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 ejemplo order_service.
  • version: versión según Semantic Versioning.
  • edition: versión del lenguaje. Edition 2024 existe desde Rust 1.85, y cargo new lo 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 feature derive de serde. Sin esta feature, el código reporta que no encuentra la macro.
  • serde_json::to_string convierte el struct a una cadena JSON.
  • serde_json::from_str hace lo opuesto. El tipo destino se deduce de la anotación Order.

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 directorio crates/.
  • [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);
    }
}
  • pub en el struct y los campos permite que el crate api acceda a ellos.
  • u64::from(line.quantity) convierte u32 a u64 sin pérdida de datos.
  • #[cfg(test)] asegura que el módulo test solo se compila cuando ejecutas cargo 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/full y el crate B solo necesita tokio/rt. Cuando compilas juntos en un workspace, B también recibe full. El código que funciona en el workspace puede fallar cuando compilas B por separado. Ejecuta cargo 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 de features = ["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 sin version. Para publicar, declara ambos: { path = "../domain", version = "0.1.0" }.
  • Workspaces anidados. Un crate pertenece solo a un workspace. Si ejecutas cargo new dentro de un subdirectorio de otro workspace, Cargo reportará un error o agregará automáticamente el crate a members. 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 usar cargo check para obtener retroalimentación rápida.
  • Agregar dependencias con cargo add, entender cómo los version requirements y Cargo.lock determinan las versiones reales, y separar dev-dependencies de las dependencias principales.
  • Agrupar múltiples crates en un workspace, declarar versiones compartidas en [workspace.dependencies] e heredarlas con workspace = 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.

rustcargodependenciaworkspaceherramienta-compilación