Cargo workspaces y features en Rust: monorepos y compilación condicional

Conforme un proyecto Rust crece, meterlo todo en un único crate se vuelve incómodo: los tiempos de compilación suben, los tests tardan y la separación de responsabilidades desaparece. Cargo ofrece dos mecanismos para estructurar proyectos grandes: los workspaces, que agrupan varios crates en un único árbol de compilación, y los features, que permiten activar código y dependencias de forma condicional.

Workspace básico

// Estructura de ficheros:
// mi-proyecto/
// ??? Cargo.toml          ? raíz del workspace
// ??? crates/
// ?   ??? core/
// ?   ?   ??? Cargo.toml
// ?   ?   ??? src/lib.rs
// ?   ??? api/
// ?   ?   ??? Cargo.toml
// ?   ?   ??? src/main.rs
// ?   ??? cli/
// ?       ??? Cargo.toml
// ?       ??? src/main.rs

// mi-proyecto/Cargo.toml (raíz)
[workspace]
members = [
    "crates/core",
    "crates/api",
    "crates/cli",
]
resolver = "2"

# Dependencias compartidas: versión unificada en todo el workspace
[workspace.dependencies]
serde      = { version = "1", features = ["derive"] }
tokio      = { version = "1", features = ["full"] }
anyhow     = "1"

Crates que comparten dependencias del workspace

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

[dependencies]
serde = { workspace = true }  # usa la versión del workspace

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

[dependencies]
core   = { path = "../core" }   # dependencia interna
tokio  = { workspace = true }
anyhow = { workspace = true }

// crates/core/src/lib.rs
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
pub struct Usuario {
    pub id: u32,
    pub nombre: String,
}

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

Comandos de workspace

// Compilar todo el workspace
// cargo build

// Ejecutar un binario concreto
// cargo run -p cli

// Tests de un crate concreto
// cargo test -p core

// Tests de todo el workspace
// cargo test --workspace

// Un único Cargo.lock para todo: versiones coherentes entre crates

Features: compilación condicional

// crates/core/Cargo.toml
[features]
default   = []          # ningún feature activo por defecto
serde     = ["dep:serde"] # activar serde solo si lo piden
async     = ["dep:tokio"]
full      = ["serde", "async"]

[dependencies]
serde  = { version = "1", features = ["derive"], optional = true }
tokio  = { version = "1", features = ["full"],   optional = true }

// crates/core/src/lib.rs
pub struct Datos {
    pub valor: i32,
}

// Código compilado solo si el feature "serde" está activo
#[cfg(feature = "serde")]
mod serde_impl {
    use super::*;
    use serde::{Serialize, Deserialize};

    impl Serialize for Datos {
        fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
            use serde::ser::SerializeStruct;
            let mut state = s.serialize_struct("Datos", 1)?;
            state.serialize_field("valor", &self.valor)?;
            state.end()
        }
    }
}

Features en dependencias opcionales

// Quién consume el crate decide qué features activar:

// Solo lo básico (sin serde ni async)
// [dependencies]
// core = { path = "crates/core" }

// Con soporte de serialización
// [dependencies]
// core = { path = "crates/core", features = ["serde"] }

// Todo incluido
// [dependencies]
// core = { path = "crates/core", features = ["full"] }

// Comprobar si un feature está activo en el código
fn main() {
    #[cfg(feature = "async")]
    println!("Modo async activo");

    #[cfg(not(feature = "async"))]
    println!("Modo síncrono");
}

cfg en atributos y código

// Función solo disponible con el feature "debug-logs"
#[cfg(feature = "debug-logs")]
pub fn log_debug(msg: &str) {
    eprintln!("[DEBUG] {msg}");
}

// Stub vacío cuando el feature no está activo
#[cfg(not(feature = "debug-logs"))]
pub fn log_debug(_msg: &str) {}

// Plataforma específica
#[cfg(target_os = "linux")]
fn ruta_config() -> &'static str { "/etc/mi-app/config.toml" }

#[cfg(target_os = "macos")]
fn ruta_config() -> &'static str { "~/Library/Application Support/mi-app/config.toml" }

#[cfg(windows)]
fn ruta_config() -> &'static str { "C:\ProgramData\mi-app\config.toml" }

Resumen

  • Un workspace agrupa varios crates con un único Cargo.lock, compilación unificada y tests centralizados.
  • [workspace.dependencies] centraliza versiones para evitar inconsistencias entre crates.
  • Los crates internos se referencian con path = "../otro-crate".
  • Los features permiten activar código y dependencias condicionalmente: quien usa la librería paga solo lo que activa.
  • optional = true en una dependencia la convierte en activable por feature.
  • #[cfg(feature = "nombre")] y el bloque cfg() controlan qué código se compila.

COMPARTE ESTE ARTÍCULO

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