wandres.dev
PUBLICAR Y ECOSISTEMA · crates.io, docs.rs

Semver y mantener un crate

Ser autor invierte la pregunta del semver: ya no consumes rangos, sino que firmas la promesa. Qué cuenta como cambio que rompe en Rust es más amplio de lo que intuyes —añadir una variante a un enum, un campo público, un método a un trait—; #[non_exhaustive] compra margen, #[deprecated] guía la transición, y cargo-semver-checks convierte la etiqueta en garantía verificable.

⏱ 20 min

En el nivel de Cargo miraste el versionado semántico desde abajo, como consumidor: serde = "1.0" era un rango y confiabas en que la mayor no rompería. Ser autor invierte la relación por completo. Ahora firmas esa promesa en cada cargo publish, y el ecosistema entero la da por buena: miles de Cargo.lock presumirán que tu 1.4.0 no romperá lo que funcionaba en 1.3.0. El problema es que «romper» en Rust significa mucho más de lo que la intuición sugiere. No es solo borrar una función: añadir una variante a un enum, un campo a un struct, un método a un trait —gestos que suenan aditivos e inocentes— puede invalidar el código de tus usuarios en silencio. Mantener un crate es, sobre todo, desarrollar el ojo para ver dónde acecha una ruptura, y aprender las herramientas que la contienen o la delatan antes de publicarla.

🎯 Al terminar esta lección sabrás
  • Reconocer qué cambios rompen en Rust, incluidos los aparentemente aditivos que sorprenden.
  • Usar #[non_exhaustive] para reservarte el derecho a crecer sin subir la mayor.
  • Guiar migraciones con #[deprecated] y ciclos de deprecación en vez de rupturas abruptas.
  • Verificar tu semver con cargo-semver-checks y honrar las expectativas de la comunidad.

Qué cuenta como romper: el catálogo incómodo

La referencia de Cargo dedica un capítulo entero a catalogar los cambios que rompen, y leerlo es descubrir cuántos parecían seguros. La intuición «añadir no rompe, quitar sí» es falsa en Rust, porque el compilador razona sobre exhaustividad, inferencia y coherencia. Los casos que más sorprenden:

  • Añadir una variante a un enum público es mayor: invalida todos los match exhaustivos de tus usuarios, que dejarán de compilar por no cubrir el nuevo caso.
  • Añadir un campo a un struct de campos públicos es mayor: rompe la construcción por literal Struct { .. } y los patrones exhaustivos.
  • Añadir un método a un trait sin implementación por defecto es mayor: los impl de tus usuarios ya no satisfacen el trait. Con un default, en cambio, suele ser menor.
  • Endurecer un límite genérico (pedir un T: Clone que antes no exigías) es mayor; relajarlo es menor.
  • Añadir un supertrait, sellar un trait, o quitarle Send a un tipo son rupturas, aunque no toques ninguna firma visible a simple vista.

Y luego lo obvio: renombrar o eliminar cualquier elemento pub, cambiar una firma, alterar un tipo de retorno. La lección es que tu API pública no son solo tus firmas: es todo lo observable por el código aguas abajo, incluida la forma en que encaja con la inferencia y la exhaustividad del compilador.

// v1.0.0
pub enum Estado { Activo, Inactivo }

// v1.1.0 -> ESTO ROMPE: un match exhaustivo del usuario ya no cubre todo
pub enum Estado { Activo, Inactivo, Suspendido }
⚠️
La mayor 0 no te exime, te compromete distinto

Recuerda del nivel de Cargo que bajo el caret 0.4 y 0.5 son incompatibles: en 0.x, la minor juega el papel de la mayor. Estar en 0.x no significa «puedo romper cuando quiera sin avisar»: significa que cada ruptura va en una nueva minor, no en un patch. Un 0.4.3 que rompe lo de 0.4.2 viola el contrato igual que un 1.4.0 que rompe 1.3.0. La versión baja concede libertad de iterar, no licencia para sorprender.

Herramientas de compatibilidad: non_exhaustive y deprecated

Rust no te deja indefenso ante el catálogo anterior: ofrece atributos para diseñar la evolución. El primero, #[non_exhaustive], se anticipa a la ruptura más común —crecer un enum o un struct—:

#[non_exhaustive]
pub enum Error { Io, Sintaxis, Rango }

Marcar así un tipo obliga a tus usuarios, desde el día uno, a incluir un brazo comodín _ => en sus match y les impide construirlo por literal. A cambio, añadir variantes o campos en el futuro deja de ser una ruptura: reservas por diseño el derecho a crecer. Es un intercambio consciente —menos ergonomía para ellos hoy, estabilidad para todos mañana— idóneo en tipos que sabes que crecerán, como los enum de error.

El segundo es #[deprecated], que sustituye la ruptura por una transición guiada:

#[deprecated(since = "1.4.0", note = "usa `parse_str` en su lugar")]
pub fn parse(s: &str) -> Config { /* ... */ }

Deprecar no elimina: la función sigue ahí y funcionando, pero cada uso emite una advertencia con tu nota. Das a tus usuarios tiempo y una ruta clara para migrar antes de que la próxima mayor la retire de verdad. Un ciclo de deprecación —deprecar en 1.4, eliminar en 2.0— es la diferencia entre un mantenedor considerado y uno que rompe por sorpresa.

🌱

#[non_exhaustive]

Reserva el derecho a añadir variantes y campos sin subir la mayor. Exige un _ => aguas abajo a cambio de futuro.

⚠️

#[deprecated]

Marca con since y note; el uso avisa pero compila. Convierte una ruptura en una migración con antelación.

🔍

cargo-semver-checks

Compara tu API con la versión publicada y te dice si el número que vas a poner es honesto.

📜

CHANGELOG.md

La narrativa que el semver no cuenta: qué cambió, qué se deprecó y cómo migrar, versión a versión.

Verificar y honrar el contrato

El semver es una promesa difícil de cumplir a ojo: ya viste que las rupturas se esconden en gestos aditivos. Por eso existe cargo-semver-checks, que compara la API pública de tu código con la de la última versión publicada y te dice si el salto que planeas es coherente:

cargo install cargo-semver-checks
cargo semver-checks check-release        # analiza cientos de reglas de compatibilidad

Si intentas publicar un 1.5.0 que en realidad rompe, la herramienta lo detecta y exige que sea 2.0.0. Integrarla en CI transforma la etiqueta semver de cortesía frágil en una garantía verificada por máquina, como un compilador de tu propia disciplina de versiones.

Más allá de la herramienta, hay expectativas que la comunidad da por sentadas. Un CHANGELOG.md legible por humanos, que el semver por sí solo no reemplaza. Una política de MSRV (rust-version) explícita: subir el compilador mínimo es un cambio con impacto y suele anunciarse, muchos lo tratan como al menos una minor. Y atención a la seguridad: la base de datos RustSec y cargo audit rastrean vulnerabilidades por versión, y publicar un aviso cuando descubres un fallo grave es parte del deber de mantener.

flowchart TD
A[Vas a publicar una nueva version] --> B{cargo-semver-checks analiza la API}
B -->|API compatible| C[Sube MINOR o PATCH]
B -->|Detecta ruptura| D[Obliga a subir la MAYOR]
C --> E[Actualiza el CHANGELOG]
D --> E
E --> F{Vas a retirar algo}
F -->|Primero| G[Deprecar con nota y aviso]
G --> H[Eliminar en la siguiente MAYOR]
style B fill:#89b4fa,color:#11111b
style D fill:#f38ba8,color:#11111b
style G fill:#f9e2af,color:#11111b
style H fill:#a6e3a1,color:#11111b
Una API pública es una superficie de comportamiento observable, no una lista de firmas: por eso romper es más ancho de lo que crees

Lo que hace del semver de Rust un tema tan denso, y tan instructivo más allá del lenguaje, es que redefine qué es «tu API». La intuición ingenua la imagina como el conjunto de nombres y firmas que declaraste pub —un contrato que solo cambia si tú tocas esas líneas—. Pero el compilador de Rust razona con exhaustividad, inferencia de tipos y coherencia de traits, y eso ensancha el contrato hasta abarcar cosas que nunca escribiste como promesa: que tu enum tiene exactamente estas variantes y no una más, para que un match sin comodín siga siendo válido; que tu struct tiene estos campos, para que un patrón exhaustivo cuadre; que tu función no es genérica, para que una inferencia concreta no se vuelva ambigua. Añadir —el gesto que en casi cualquier otro lenguaje es seguro por definición— puede romper aquí porque el usuario dependía, sin saberlo y sin que tú se lo prometieras explícitamente, de la ausencia de lo que añades. Esta es la revelación profunda: la superficie de compatibilidad de un sistema no es lo que su autor cree exponer, sino todo lo que su consumidor puede observar y sobre lo que puede construir. Y de ahí se sigue toda la ingeniería de la estabilidad. #[non_exhaustive] no es un truco: es hacer explícita una promesa que de otro modo se firmaría por accidente —«no cuentes con que estas son todas las variantes»—, devolviéndote el control sobre tu propio contrato. cargo-semver-checks no es un lujo: es la admisión de que ningún humano puede rastrear a mano una superficie tan ancha, y que la disciplina que importa hay que mecanizarla para que se cumpla. La lección trasciende Rust y trasciende el software: cuando otros construyen sobre lo que haces, tu libertad de cambiar queda acotada no por lo que prometiste, sino por aquello de lo que, en la práctica, llegaron a depender. Diseñar para la evolución es, antes que nada, controlar deliberadamente qué dejas que observen.

📝
Lo esencial de mantener un crate

Romper en Rust es más amplio que borrar: añadir una variante a un enum, un campo público, un método sin default, o endurecer un bound, son cambios mayores. #[non_exhaustive] reserva el derecho a crecer a cambio de un _ => aguas abajo. #[deprecated(since, note)] convierte una ruptura en migración guiada; deprecar en una minor, eliminar en la siguiente mayor. cargo-semver-checks verifica que tu número sea honesto. Honra además un CHANGELOG.md, una política de MSRV explícita y los avisos de RustSec.

⚔️ Piensa como quien mantiene, no como quien escribe
  1. Toma un enum público, añádele una variante y compila un usuario con match exhaustivo: observa la ruptura. Ahora marca el enum #[non_exhaustive] y repite.
  2. Clasifica como mayor, menor o patch: relajar un bound, añadir un método con default, quitar Send a un tipo, renombrar un campo privado. Justifica cada uno.
  3. Depreca una función con #[deprecated(since, note)], usa la función y lee la advertencia. Planifica en qué versión la eliminarías.
  4. Instala cargo-semver-checks, introduce un cambio que rompa y confirma que la herramienta exige subir la mayor.
  5. Redacta la entrada de CHANGELOG.md para una versión que depreca una función y añade otra, siguiendo el formato «Added / Changed / Deprecated».