# Turning a Rust struct into an enum is not always a major breaking change

_Published: 2023-01-24_

_**EDIT**: The Rust [API evolution RFC](https://rust-lang.github.io/rfcs/1105-api-evolution.html#detailed-design) 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](https://www.reddit.com/r/rust/comments/10k0eox/comment/j5nrm6z/)) but is not always major (equivalent to [this case](https://rust-lang.github.io/rfcs/1105-api-evolution.html#minor-change-going-from-a-tuple-struct-with-all-private-fields-with-at-least-one-field-to-a-normal-struct-or-vice-versa)). In this post, we're trying to avoid making a major change._[^sn-1]

Upholding semantic versioning [is critical in the Rust ecosystem](/blog/2022-08-25-toward-fearless-cargo-update/).
But semver has a million non-obvious edge cases.
It's [unreasonable to expect](/blog/2022-12-23-cargo-semver-checks-today-and-in-2023/) 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` or `impl 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](https://doc.rust-lang.org/reference/special-types-and-traits.html#auto-traits) — we don't want to find out our new type is not `Send` or `Sync` [like in a prior post](/blog/2022-08-25-toward-fearless-cargo-update/).

As of v0.16.0, `cargo-semver-checks` can automatically check _some of this_:
- [The `inherent_method_missing` lint](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/inherent_method_missing.ron) ensure that all inherent methods (ones in `impl Chameleon`, not `impl Foo for Chameleon`) continue to exist.
- Other lints check that methods [did not change their number of parameters](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/method_parameter_count_changed.ron), [did not stop being `const`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/inherent_method_const_removed.ron), [did not become `unsafe`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/inherent_method_unsafe_added.ron), etc.
- The [`auto_trait_impl_removed`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/auto_trait_impl_removed.ron), [`sized_impl_removed`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/sized_impl_removed.ron), and [`derive_trait_impl_removed`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/derive_trait_impl_removed.ron) lints check that [auto-traits](https://doc.rust-lang.org/reference/special-types-and-traits.html#auto-traits), the special [`Sized` marker trait](https://doc.rust-lang.org/reference/special-types-and-traits.html#sized), and [built-in traits used in `#[derive(...)]`](https://doc.rust-lang.org/reference/attributes/derive.html) 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](https://github.com/obi1kenobi/cargo-semver-checks/issues/5).

## Public fields

Rust structs may have publicly-visibile fields:
```rust
pub struct NotChameleon {
    pub name: String,
}
```

Given such a struct, code in another crate is allowed to read or mutate that field directly:
```rust
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](https://doc.rust-lang.org/rust-by-example/custom_types/enum.html).
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:
```rust
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`](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/struct_with_pub_fields_changed_type.ron).
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](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/struct_with_pub_fields_changed_type.ron) [with its lints](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/unit_struct_changed_kind.ron).

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`](https://github.com/oli-obk/cargo_metadata/blob/main/.github/workflows/release.yml#L51-L61):

![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"](/blog/2023-01-24-turning-rust-struct-to-enum-is-not-always-breaking/caught-semver-violation.png)

Here are examples of struct literal syntax for unit, tuple, and plain structs:
```rust
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](https://doc.rust-lang.org/reference/attributes/type_system.html) 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](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/struct_marked_non_exhaustive.ron) [always](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/enum_marked_non_exhaustive.ron) [catch](https://github.com/obi1kenobi/cargo-semver-checks/blob/main/src/lints/variant_marked_non_exhaustive.ron).

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](https://github.com/obi1kenobi/cargo-semver-checks/issues?q=is%3Aissue+is%3Aopen+label%3AC-enhancement).

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](/blog/2022-08-25-toward-fearless-cargo-update/).

What's stopping your crate from adopting `cargo-semver-checks` today?
I'd love to [hear about it](https://hachyderm.io/@predrag) and resolve it!

[^sn-1]: The title and content has been slightly edited since the original publication for clarity on the "major vs breaking" point.

Copyright (C) Predrag Gruevski 2023. [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en)
