EDIT: The Rust API evolution RFC distinguishes between breaking changes and changes that require a new semver-major version (called major changes). All major changes are breaking, but not all breaking changes are major. Changing a struct to an enum is always breaking (as pointed out on r/rust) but is not always major (equivalent to this case). In this post, we're trying to avoid making a major change. The title and content has been slightly edited since the original publication for clarity on the "major vs breaking" point.
Upholding semantic versioning is critical in the Rust ecosystem. But semver has a million non-obvious edge cases. It's unreasonable to expect all Rustaceans to master semver trivia to be able to safely publish crates — and it doesn't make for a welcoming, inclusive environment, either.
cargo-semver-checks
cares about the edge cases of semver compliance so you won't have to.
To do that, it must also correctly implement all those edge cases.
Every so often, my work on cargo-semver-checks
leads me to a semver edge case that surprises me.
For example: turning a Rust struct into an enum doesn't necessarily require a major version! Here's how!
Our protagonist in this post is pub struct Chameleon
.
Our goal is to turn it into pub enum Chameleon
without needing to release v2.0 of our (fictional) crate.
Not every struct is capable of this feat, so we'll show how our little camouflaged friend manages to blend into the environment so well.
Implemented methods and traits
One possible major breaking change is removing the implementation of a method or trait on pub enum Chameleon
when that method or trait were implemented for the old pub struct Chameleon
.
Any code that relies on that method or trait will be broken when the method or trait disappears.
In fact, many kinds of major breaking changes are possible within impl
blocks.
To make things easy on ourselves:
- We won't change or remove any
#[derive(...)]
or similar derive-macro attributes that already existed on the struct. - We'll be careful when altering existing
impl Chameleon
orimpl SomeTrait for Chameleon
blocks. Removing or altering methods, changing implemented traits' associated types, or changing the bounds on a trait implementation can all be major breaking changes. - We'll keep an eye on our type's auto-traits — we don't want to find out our new type is not
Send
orSync
like in a prior post.
As of v0.16.0, cargo-semver-checks
can automatically check some of this:
- The
inherent_method_missing
lint ensure that all inherent methods (ones inimpl Chameleon
, notimpl Foo for Chameleon
) continue to exist. - Other lints check that methods did not change their number of parameters, did not stop being
const
, did not becomeunsafe
, etc. - The
auto_trait_impl_removed
,sized_impl_removed
, andderive_trait_impl_removed
lints check that auto-traits, the specialSized
marker trait, and built-in traits used in#[derive(...)]
did not stop being implemented. - As of v0.16,
cargo-semver-checks
is not able to check method parameter or return types, trait associated types, or generic bounds. Here's our tracking issue for not-yet-implemented lints.
Public fields
Rust structs may have publicly-visibile fields:
pub struct NotChameleon {
pub name: String,
}
Given such a struct, code in another crate is allowed to read or mutate that field directly:
fn rename(value: &mut NotChameleon) {
println!("Current name: {}", value.name); // reading the field
value.name = "Not a chameleon".to_string(); // mutating the field
}
Rust enums cannot have fields — they only have variants.
If pub struct NotChameleon
here became an enum, all accesses of the name
field would be broken since that field no longer exists!
This is why our pub struct Chameleon
must not expose any fields directly.
Instead, it uses accessor methods to provide both immutable and mutable access to its contents:
pub struct Chameleon {
name: String
}
impl Chameleon {
pub fn name(&self) -> &str {
&self.name
}
// one option: an explicit setter
pub fn set_name(&mut self, name: String) {
self.name = name;
}
// another option, useful to allow appending
// to the existing string without copying first
pub fn name_mut(&mut self) -> &mut String {
&mut self.name
}
}
The implementation of these accessor methods may need to change as a result of the switch from struct to enum, but those changes are internal implementation details. The methods' callers will remain blissfully ignorant of the change.
Since the NotChameleon
struct has public fields, attempting to turn it into an enum without incrementing the major version will trigger a lint in cargo-semver-checks
.
Our friend Chameleon
has no public fields, and won't have that problem.
Surprise! One more thing to consider
Just for fun, let's now say that Chameleon
has no fields whatsoever — not even private fields.
Perhaps it's a unit struct like pub struct Chameleon
or an empty tuple struct like pub struct Chameleon()
, or even an empty plain struct like pub struct Chameleon {}
.
Quick aside: did you know that changing between those three struct kinds can be a major breaking change?
Don't worry, cargo-semver-checks
has you covered with its lints.
Anyway...
Since the struct has no fields, there are no field accesses to worry about when changing it to an enum.
We'll leave all the impl
blocks and #[derive(...)]
attributes untouched, so all the methods and traits will be fine.
Looks good to me, ship it! 🚀
💥 Oops!! 💥 We broke semver 😬
Thankfully cargo-semver-checks
runs in our release pipeline before cargo publish
:
Here are examples of struct literal syntax for unit, tuple, and plain structs:
let _ = UnitStruct;
let _ = EmptyTupleStruct();
let _ = EmptyPlainStruct {};
If the Chameleon
struct was usable like this, converting it to an enum would be a major breaking change.
We couldn't create an enum value like this — we have to specify a particular enum variant, not just supply the enum's name with parens or curly braces.
Fortunately, Rust's built-in #[non_exhaustive]
attribute can prevent other crates from using the literal syntax to directly create Chameleon
values, instead requiring them to use a constructor like Chameleon::new()
.
But be careful: adding #[non_exhaustive]
to an existing type or enum variant is itself a major breaking change — one that cargo-semver-checks
will always catch.
So for the Chameleon
struct with no fields to be able to become an enum without a major change, it must have been marked #[non_exhaustive]
at the time it was originally added to the public API.
One day, cargo-semver-checks
might even start suggesting that you consider adding #[non_exhaustive]
when you add a type like this to your crate's public API — but that's a topic for a future post.
Conclusion
Semver violations are much like memory safety violations:
- They can happen to anyone, even if you're extremely careful.
- There's an overwhelming amount of real-world evidence of the above.
- Automated tools can prevent semver violations, or at least significantly reduce their likelihood.
cargo-semver-checks
is not yet a perfect tool.
For example, it catches an ever-growing yet still incomplete list of semver issues.
At the same time, adopting cargo-semver-checks
v0.16 today means 40 fewer possible kinds of semver violations in your crate's API.
Specifically, it will prevent exactly the semver violations that have already happened in past versions of clap
, pyo3
, and many other top crates.
What's stopping your crate from adopting cargo-semver-checks
today?
I'd love to hear about it and resolve it!