Cargo avanzado en Rust: workspaces, features condicionales, build.rs y cargo-expand

Cargo es mucho más que un gestor de dependencias. Ofrece workspaces para organizar proyectos con múltiples crates, features para compilación condicional, build scripts para generación de código previa a la compilación, y herramientas como cargo-expand para depurar macros. Dominar estas funcionalidades permite escalar proyectos Rust de forma ordenada.

Workspaces: proyectos multicrate con Cargo.lock compartido

Un workspace agrupa varios crates relacionados bajo un único Cargo.toml raíz. Todos comparten el mismo Cargo.lock, lo que garantiza versiones coherentes de dependencias en todo el proyecto. Los comandos de Cargo afectan a todos los crates por defecto, o a uno concreto con -p nombre_crate.

// Estructura de directorios:
// mi-proyecto/
//   Cargo.toml        (workspace root)
//   core/
//     Cargo.toml
//     src/lib.rs
//   api/
//     Cargo.toml
//     src/main.rs
//   cli/
//     Cargo.toml
//     src/main.rs
# Cargo.toml (raíz del workspace)
[workspace]
members = ["core", "api", "cli"]
resolver = "2"

[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }

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

[dependencies]
serde = { workspace = true }

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

[dependencies]
core = { path = "../core" }
tokio = { workspace = true }
// Comandos útiles con workspace:
// cargo build -p core          -- compilar solo core
// cargo test --workspace       -- tests de todos los crates
// cargo clippy --workspace     -- linting en todo el workspace
// cargo doc --workspace --open -- documentación de todos los crates

Features condicionales

Las features permiten compilar código opcional. Los usuarios del crate activan las que necesitan; el resto no se compila. Esto reduce el tamaño del binario y las dependencias transitivas.

# Cargo.toml
[features]
default = ["json"]
json = ["serde", "serde_json"]
yaml = ["serde", "serde_yaml"]
full = ["json", "yaml"]

[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
serde_yaml = { version = "0.9", optional = true }
// src/lib.rs
pub fn serializar(datos: &str) -> String {
    #[cfg(feature = "json")]
    return format!("{{"data":"{datos}"}}");

    #[cfg(not(feature = "json"))]
    datos.to_string()
}

#[cfg(feature = "yaml")]
pub fn serializar_yaml(clave: &str, valor: &str) -> String {
    format!("{clave}: {valor}n")
}

// Activar features desde la línea de comandos:
// cargo build --features yaml
// cargo build --all-features
// cargo build --no-default-features

build.rs: generación de código antes de compilar

build.rs es un script de Rust que Cargo ejecuta antes de compilar el crate principal. Sirve para generar código, compilar bibliotecas C, enlazar librerías nativas o leer variables de entorno de CI.

// build.rs (en la raíz del crate, junto a Cargo.toml)
use std::fs;
use std::env;
use std::path::PathBuf;

fn main() {
    // Leer variable de entorno de build y exponerla al código
    let version = env::var("BUILD_VERSION").unwrap_or_else(|_| "0.0.0".into());

    // Generar un archivo Rust con la versión embebida
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    let dest = out_dir.join("version.rs");
    fs::write(&dest, format!(
        "pub const BUILD_VERSION: &str = "{version}";n"
    )).unwrap();

    // Decirle a Cargo cuándo re-ejecutar el build script
    println!("cargo:rerun-if-env-changed=BUILD_VERSION");
    println!("cargo:rerun-if-changed=build.rs");
}

// src/lib.rs — incluir el archivo generado
include!(concat!(env!("OUT_DIR"), "/version.rs"));

pub fn version() -> &'static str {
    BUILD_VERSION
}

cargo-expand: ver las macros expandidas

cargo-expand muestra el código Rust resultante después de expandir todas las macros del archivo. Es imprescindible para depurar #[derive] personalizados, proc-macros y macros declarativas complejas.

// Instalación:
// cargo install cargo-expand

// Uso básico:
// cargo expand                    -- expande todo el crate
// cargo expand --bin mi-binario   -- expande un binario concreto
// cargo expand mi_modulo          -- expande un módulo concreto

// Ejemplo: ver qué genera #[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq)]
pub struct Punto {
    pub x: f64,
    pub y: f64,
}

// cargo expand produce algo similar a:
// impl std::fmt::Debug for Punto {
//     fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
//         f.debug_struct("Punto")
//             .field("x", &self.x)
//             .field("y", &self.y)
//             .finish()
//     }
// }
// impl Clone for Punto { ... }
// impl PartialEq for Punto { ... }

Un flujo de trabajo habitual en proyectos grandes: define las dependencias compartidas en [workspace.dependencies] para mantener versiones coherentes, usa features para que cada binario solo compile lo que necesita, añade un build.rs para inyectar metadatos de CI (versión, commit hash, fecha de build), y usa cargo-expand cuando alguna macro no produce el código esperado. Juntas, estas herramientas hacen que Cargo escale bien desde proyectos pequeños hasta monorepos con decenas de crates.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP