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

Cargo.toml, dependencias y SEMVER

El manifiesto declara rangos de versión, el lockfile congela versiones exactas y, entre ambos, el resolvedor aplica versionado semántico para elegir la más alta compatible. Por qué serde = 1.0 no pide la 1.0.0, cómo conviven dos versiones mayores del mismo crate y cuándo versionar Cargo.lock.

⏱ 20 min

Una dependencia en Rust no es una versión: es un rango. Cuando escribes serde = "1.0" no reclamas la versión 1.0.0, declaras que aceptas cualquier versión compatible según el versionado semántico y delegas en Cargo la elección concreta. Ese contrato de tres piezas —el manifiesto Cargo.toml, que fija rangos; el resolvedor, que elige dentro de ellos; y el Cargo.lock, que congela el resultado exacto con su checksum— es la maquinaria que vuelve reproducibles compilaciones que penden de miles de crates transitivos. Dominarla es jubilar el «en mi máquina compilaba» para siempre.

🎯 Al terminar esta lección sabrás
  • Traducir cualquier requisito de versión al rango de >= y < que Cargo aplica.
  • Separar el papel de Cargo.toml (rangos) del de Cargo.lock (versiones exactas).
  • Explicar cómo el resolvedor elige versiones y por qué conviven varias mayores.
  • Decidir cuándo versionar el lockfile y cómo actualizar sin romper nada.

El manifiesto y el operador caret implícito

En [dependencies] cada valor de versión es un requisito, no una versión fija. Y el operador por defecto —el que actúa cuando no escribes ninguno— es el caret (^):

[dependencies]
serde = "1.0"                                    # equivale a ^1.0
rand  = { version = "0.9", default-features = false }
regex = { version = "1", features = ["unicode"] }
tokio = { version = "1", features = ["full"] }

La regla del caret cabe en una frase: preserva el dígito distinto de cero situado más a la izquierda. De ahí se deduce todo su comportamiento, que cambia de forma sutil según la versión sea mayor o menor que 1:

  • ^1.2.3 equivale a >=1.2.3, <2.0.0: todo lo que comparta la mayor 1.
  • ^0.2.3 equivale a >=0.2.3, <0.3.0: con mayor 0, la minor pasa a ser la barrera.
  • ^0.0.3 equivale a >=0.0.3, <0.0.4: con dos ceros, solo ese patch exacto.

La lógica es que antes de la 1.0 la API se considera inestable, así que Cargo trata el primer dígito no nulo como si fuera la mayor. Junto al caret existen otros operadores para cuando necesitas control fino:

[dependencies]
a = "~1.2.3"          # tilde: >=1.2.3, <1.3.0, fija la minor
b = "1.2.*"           # comodin: >=1.2.0, <1.3.0
c = ">=1.2, <1.5"     # rango compuesto explicito
d = "=1.2.3"          # exacta: solo 1.2.3, casi siempre mala idea

Fijar con = una versión exacta parece prudente y casi siempre es un error: rompe la unificación —que veremos enseguida— y te aísla de los parches de seguridad. El rango es una virtud del diseño, no un riesgo que haya que neutralizar.

🔼

Caret (por defecto)

"1.2.3" es ^1.2.3, o sea >=1.2.3, <2.0.0. Preserva el primer dígito no nulo. El operador que usas el 95% del tiempo.

〰️

Tilde

"~1.2.3" es >=1.2.3, <1.3.0. Fija la minor y solo deja avanzar el patch. Para cuando la minor te da miedo.

✳️

Comodín

"1.2.*" es >=1.2.0, <1.3.0. Un asterismo por posición. Legible, pero el caret suele decir lo mismo con más intención.

🔒

Exacta e igual

"=1.2.3" clava una sola versión. Rompe la unificación y frena los parches: resérvalo para reproducir un bug puntual.

Clases y fuentes de dependencia

Una dependencia no tiene por qué venir de crates.io ni ser toda de la misma clase. Cargo distingue tres tablas según cuándo se usa la dependencia, y admite varias fuentes de dónde sale:

[dependencies]
serde = "1"                                   # de crates.io, el registro por defecto
local = { path = "../local" }                 # de una ruta del disco
axum  = { git = "https://github.com/tokio-rs/axum", tag = "v0.7.0" }

[dev-dependencies]
criterion = "0.5"     # solo para tests, benchmarks y ejemplos

[build-dependencies]
cc = "1"              # solo para el build.rs, se compila para tu maquina

Las [dev-dependencies] no entran en tu binario ni en el árbol de quien te consume: existen solo al probar. Las [build-dependencies] sirven al build script y se compilan para el host, no para el destino. Y una dependencia puede renombrarse con package, útil cuando dos crates chocan de nombre o quieres un alias local:

[dependencies]
serde1 = { package = "serde", version = "1" }

SEMVER: el contrato social de las versiones

El versionado semántico —MAJOR.MINOR.PATCH— es una promesa que el autor firma en cada publicación. Subir la PATCH promete solo correcciones; la MINOR, funcionalidad añadida sin romper lo anterior; la MAJOR, un cambio que puede romper a quien te usa. El caret de Cargo confía en esa promesa: misma mayor, presunción de compatibilidad.

Lo delicado es qué cuenta como «romper» en Rust, y la respuesta es más amplia de lo que intuyes. El capítulo de SemVer del libro de Cargo lo cataloga: añadir una variante a un enum público es un cambio mayor, porque invalida los match exhaustivos de tus usuarios; añadir un método puede romper por inferencia o por colisión con el de un trait. Para eso existe #[non_exhaustive]: marcar así un enum o un struct te reserva el derecho de crecer sin subir la mayor, a cambio de obligar a tus usuarios a un brazo comodín _ =>.

ℹ️
La mayor 0 no es un ensayo, es un régimen

Un crate en 0.x no está «sin terminar»: declara que cada minor puede romper. Miles de crates fundamentales viven años en 0.x de forma deliberada. Bajo el caret, 0.4 y 0.5 son incompatibles, así que saltar de uno a otro es una migración, no un parche. No confundas número bajo con inmadurez.

El resolvedor, el lockfile y la convivencia de mayores

Cuando dos crates de tu árbol dependen de rand, el resolvedor intenta unificar: busca una única versión que satisfaga ambos rangos y la comparte. Pero si uno exige ^0.8 y otro ^0.9 —dos mayores incompatibles—, Cargo no se rinde: enlaza las dos a la vez. Coexisten porque, para el compilador, rand 0.8::Rng y rand 0.9::Rng son tipos distintos. Esto disuelve el clásico «infierno de dependencias», pero tiene una arista: si pasas un valor de una a una función de la otra, verás el desconcertante «se esperaba Rng, se encontró Rng».

flowchart TD
APP[Tu binario] --> A[crate a pide rand 0.9]
APP --> B[crate b pide rand 0.9]
APP --> C[crate c pide rand 0.8]
A --> U1[rand 0.9.x unificada]
B --> U1
C --> U2[rand 0.8.x en paralelo]
style U1 fill:#a6e3a1,color:#11111b
style U2 fill:#f9e2af,color:#11111b

El resultado de esa resolución se escribe en Cargo.lock: versión exacta y checksum de cada crate del árbol transitivo. Con el lockfile presente, cargo build no vuelve a resolver, reproduce bit a bit el mismo grafo. La edición 2024 estrena el resolvedor 3, consciente del campo rust-version: nunca elige una versión de dependencia que exija un compilador más nuevo que el mínimo que declaras.

cargo update                              # recalcula el lock dentro de los rangos
cargo update -p serde --precise 1.0.150   # fija una version puntual
cargo tree -d                             # muestra duplicados: dos mayores del mismo crate
Separar la intención del resultado es lo que hace reproducible lo flexible

Detente en la arquitectura de esta decisión, porque es más honda que un formato de fichero. Cargo separa tres cosas que otros ecosistemas confunden en una: lo que tú aceptas —el rango en Cargo.toml, pura intención humana—, lo que de hecho obtuviste —la versión exacta en Cargo.lock, un hecho reproducible— y la regla que decide si un cambio es admisible —el versionado semántico, un contrato verificable por máquina—. Los gestores que solo guardan «la versión que instalé» pierden la intención y no saben actualizar con criterio; los que solo guardan rangos pierden la reproducibilidad y compilan algo distinto cada día. Al mantener las tres piezas ortogonales, Cargo obtiene a la vez flexibilidad —elige la más alta compatible, absorbe un parche de seguridad sin que toques nada— y determinismo —el lockfile garantiza que tu CI, tu portátil y la máquina de tu colega producen idénticos artefactos—. Y la convivencia de mayores incompatibles remata la jugada: al permitir que rand 0.8 y rand 0.9 vivan juntas, elimina la restricción global de «una sola versión de cada cosa» que condena a otros lenguajes a bloqueos irresolubles. El precio, tipos que no se mezclan entre mayores, es local y diagnosticable; la recompensa, un árbol de quinientos crates que compila hoy igual que dentro de un año, es estructural. No es azar que el ecosistema de Rust crezca sin colapsar bajo su propio peso: la reproducibilidad no está atornillada encima, está en los cimientos.

📝
Lo esencial de semver en Cargo

En Cargo.toml declaras rangos; el caret es el operador por defecto y preserva el primer dígito no nulo (^1.2.3 es >=1.2.3, <2.0.0; ^0.2.3 es >=0.2.3, <0.3.0). SemVer es la promesa del autor: misma mayor, compatible. Cargo.lock congela versiones exactas con checksum; versiónalo siempre en binarios y hoy también en librerías para un CI reproducible. Dos mayores incompatibles del mismo crate coexisten como tipos distintos. cargo update respeta los rangos; --precise fija una versión concreta.

⚔️ Lee los rangos como los lee Cargo
  1. Toma regex = "1.9.3" y escribe a mano el rango >=, < que genera. Repítelo para "0.9.3" y para "0.0.3"; explica por qué difieren.
  2. Crea un proyecto, añade rand con cargo add rand y abre Cargo.lock: localiza la versión exacta y el checksum que Cargo eligió.
  3. Fuerza la convivencia de dos mayores: depende de un crate que aún use rand 0.8 y añade rand 0.9 directo. Confirma el duplicado con cargo tree -d.
  4. Ejecuta cargo update -p <crate> --precise <version> para bajar una dependencia una versión y observa el cambio exacto en el lockfile.
  5. Declara =1.0.0 en una dependencia popular y razona qué problemas de unificación podrías provocar en un árbol grande.