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:

As of v0.16.0, cargo-semver-checks can automatically check some of this:

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:

Terminal output from cargo-semver-checks showing a 'constructible_struct_changed_type' lint. The lint's description says "A struct became an enum or union, and is no longer publicly constructible with a struct literal." The lint specifies the offending code as "struct semver_example::Chameleon became enum in file src/lib.rs:1"

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:

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!