wandres.dev
CARGO A FONDO · workspaces, features, perfiles

Cargo extendido: build.rs, install y subcomandos

Cargo no es un monolito, es un protocolo. Los build scripts ejecutan código antes de compilar, cargo install pone binarios en tu PATH, y una regla de una línea —cualquier cargo-foo del PATH es un subcomando— convierte a la comunidad en autora de la propia herramienta. clippy, fmt, expand, machete, audit y nextest son el ecosistema que emerge de esa costura.

⏱ 20 min

Cargo parece una herramienta cerrada y en realidad es un protocolo abierto. Bajo la superficie de build y test hay tres mecanismos de extensión que multiplican lo que puede hacer sin que su núcleo crezca: los build scripts (build.rs), que ejecutan código tuyo antes de compilar; cargo install, que instala binarios de la comunidad en tu PATH; y una convención de una sola línea —cualquier ejecutable llamado cargo-algo en el PATH se invoca como cargo algo— que convierte a cualquiera en autor de un subcomando. De esa costura ha brotado un ecosistema —clippy, fmt, expand, machete, audit, nextest— que ninguna hoja de ruta central diseñó. Esta lección es sobre esa arquitectura de la extensibilidad.

🎯 Al terminar esta lección sabrás
  • Escribir un build.rs que emita instrucciones al compilador y genere código.
  • Instalar binarios con cargo install y fijar reproducibilidad con --locked.
  • Entender por qué cualquier cargo-foo del PATH se vuelve un subcomando.
  • Conocer el ecosistema esencial: clippy, fmt, expand, machete, audit, nextest.

build.rs: código que corre antes de compilar

Un build script es un fichero build.rs en la raíz del paquete. Cargo lo compila y lo ejecuta en tu máquina antes de compilar el crate, y se comunica con Cargo imprimiendo líneas con el prefijo cargo:: —la sintaxis de doble dos puntos de las ediciones recientes; el antiguo cargo: de un solo colon está obsoleto—.

use std::{env, fs, path::Path};

fn main() {
    // Recompila solo si cambia esta entrada, no en cada build:
    println!("cargo::rerun-if-changed=plantillas/tabla.txt");

    // Genera codigo en OUT_DIR para incluirlo desde el crate:
    let salida = env::var("OUT_DIR").unwrap();
    let destino = Path::new(&salida).join("tabla.rs");
    fs::write(&destino, "pub const N: usize = 256;").unwrap();

    // Enlaza una libreria nativa y define un cfg a medida:
    println!("cargo::rustc-link-lib=z");
    println!("cargo::rustc-cfg=tiene_zlib");
}

El código generado se incorpora con include!(concat!(env!("OUT_DIR"), "/tabla.rs")). Los usos canónicos: compilar código C con el crate cc, generar bindings de FFI con bindgen, incrustar tablas precalculadas o sondear el entorno. Las herramientas que el script necesita van en [build-dependencies], una sección aparte porque se compilan para tu máquina, no para el destino.

El caso más frecuente es compilar código C junto a tu crate, y el crate cc colapsa ese build.rs a unas líneas: detecta el compilador del sistema, aplica las banderas correctas y enlaza el resultado sin que toques un Makefile.

fn main() {
    cc::Build::new()
        .file("src/nativo.c")
        .opt_level(2)
        .compile("nativo");              // produce libnativo.a y la enlaza
    println!("cargo::rerun-if-changed=src/nativo.c");
}
⚠️
Un build.rs ejecuta código arbitrario al compilar

Recuerda qué acabas de aceptar: al añadir una dependencia con build.rs, autorizas a que se ejecute código de un tercero en tu máquina la primera vez que compilas, antes incluso de correr el programa. Es la principal superficie de ataque de la cadena de suministro en Rust. Por eso importan cargo audit, cargo vet y compilar en CI aislado: la comodidad del codegen automático viene con una confianza que conviene verificar.

cargo install y la extensibilidad por convención

cargo install compila un crate binario de crates.io y deja su ejecutable en ~/.cargo/bin, que sueles tener en el PATH. Es como se distribuyen las herramientas de línea de comandos escritas en Rust. La bandera --locked respeta el Cargo.lock que el autor publicó, garantizando una compilación reproducible en vez de resolver versiones nuevas.

cargo install ripgrep --locked      # instala el binario rg en ~/.cargo/bin
cargo install --path .              # instala el binario del crate actual
cargo install cargo-nextest --locked
cargo install --list                # que tienes instalado
cargo --list                        # que subcomandos reconoce cargo ahora mismo

Fíjate en el nombre cargo-nextest. Ahí está la regla que lo cambia todo: cualquier ejecutable llamado cargo-algo que esté en tu PATH se invoca como cargo algo. Cargo, al recibir un subcomando que no reconoce, busca un binario con ese nombre y se lo delega. No hay registro, ni permiso, ni coordinación: instalas cargo-nextest y, sin más, cargo nextest existe.

El ecosistema que emerge de la costura

Sobre esa convención ha crecido un catálogo de subcomandos que se sienten parte de Cargo aunque son proyectos independientes. clippy y rustfmt llegan como componentes de rustup; el resto se instalan con cargo install.

rustup component add clippy rustfmt
cargo clippy -- -D warnings         # lints como errores: imprescindible en CI
cargo fmt --check                   # verifica el formato sin tocar nada
cargo expand                        # muestra el codigo tras expandir las macros
cargo machete                       # dependencias declaradas y jamas usadas
cargo audit                         # vulnerabilidades conocidas via RUSTSEC
cargo semver-checks                 # comprueba que no rompes semver al publicar

Y la lista sigue: cargo deny aplica políticas de licencias y duplicados, cargo hack prueba el conjunto potencia de features, cargo flamegraph y cargo bloat diagnostican tiempo y tamaño, y cargo binstall baja binarios ya compilados en vez de recompilarlos. Todos obedecen la misma convención de nombres, y ninguno tuvo que pedir permiso al equipo de Cargo para existir.

📎

clippy

Cientos de lints más allá del compilador: patrones lentos, código redundante, riesgos sutiles. Con -D warnings, tu red de seguridad en CI.

🎨

fmt

rustfmt: formato canónico y no negociable. --check falla si algo no está formateado, y así muere la discusión de estilo.

🔬

expand

Imprime el código tras expandir derive y macros. La herramienta para entender qué escribe de verdad #[derive(...)].

🧹

machete

Detecta dependencias declaradas en Cargo.toml que ya no usas. Adelgaza el árbol y acorta la compilación.

🛡️

audit

Contrasta tu lockfile con la base RUSTSEC y avisa de crates con vulnerabilidades conocidas. Vital en la cadena de suministro.

🚀

nextest

Un ejecutor de tests más rápido y con mejor salida que el integrado. Aislamiento por proceso y reintentos configurables.

flowchart TD
U[Escribes cargo algo] --> D[Cargo busca el subcomando]
D --> B[Integrado: build test run install]
D --> E[Externo: binario cargo-algo en el PATH]
E --> N[nextest audit machete expand semver-checks]
style D fill:#cba6f7,color:#11111b
style E fill:#f9e2af,color:#11111b
Cargo no es un monolito, es un despachador con un protocolo de nombres

La convención cargo-algo es, en una sola regla, la arquitectura entera de la herramienta y la explicación de una paradoja. Cargo no es un programa monolítico que un equipo central debe ampliar función a función; es un despachador con un protocolo de nombres tan simple que cabe en una frase: un binario llamado cargo-X en tu PATH es el subcomando cargo X. Esa línea convierte a cada desarrollador del mundo en un colaborador potencial del propio tooling, sin coordinación, sin permiso, sin pasar por nadie. El resultado es que el catálogo de subcomandos —audit, nextest, machete, expand, flamegraph, semver-checks, deny, hack— es emergente: no lo diseñó ninguna hoja de ruta, brotó de la costura que el núcleo dejó abierta a propósito. Es la filosofía Unix —programas pequeños que componen, descubiertos por convención— aplicada a un sistema de compilación, y explica por qué el instrumental de Rust, respaldado por una fracción del dinero corporativo que sostiene a otros lenguajes, resulta más ancho y más afilado: el equipo central mantuvo el núcleo pequeño y abrió una junta, y la comunidad la rellenó. La consecuencia para ti es liberadora. Cuando un día escribas tu propio cargo-loquesea —un guión que automatice el ritual de tu proyecto— no estarás hackeando alrededor de Cargo ni forzando nada: lo estarás usando exactamente como fue concebido. La extensibilidad no es una función de Cargo; es su forma.

📝
Lo esencial de Cargo extendido

build.rs ejecuta código en tu máquina antes de compilar y habla con Cargo por líneas cargo:: (genera código en OUT_DIR, enlaza libs nativas, define cfg); sus herramientas van en [build-dependencies] y son una superficie de cadena de suministro. cargo install --locked pone binarios reproducibles en ~/.cargo/bin. La regla de oro: cualquier cargo-algo del PATH es un subcomando. De ahí el ecosistema: clippy y fmt (vía rustup), y expand, machete, audit, nextest y semver-checks (vía install).

⚔️ Extiende Cargo con tus manos
  1. Escribe un build.rs que genere una constante en OUT_DIR e inclúyela con include!. Añade cargo::rerun-if-changed y comprueba que solo se reejecuta al cambiar la entrada.
  2. Instala ripgrep con cargo install ripgrep --locked y localiza el binario en ~/.cargo/bin. Explica qué garantiza --locked.
  3. Añade clippy y rustfmt con rustup component add, corre cargo clippy -- -D warnings sobre un crate con un patrón mejorable y arréglalo.
  4. Instala cargo-expand y observa qué genera un #[derive(Debug)]; contrasta el volumen de código con la única línea que escribiste.
  5. Crea un ejecutable llamado cargo-saludo (un guión que imprima algo) y ponlo en el PATH. Invócalo como cargo saludo y explica por qué funciona sin haberlo registrado en ningún sitio.