# Some Rust breaking changes don't require a major version

_Published: 2023-01-26_

I've [been saying](/blog/2022-08-25-toward-fearless-cargo-update/) [for a while now](/blog/2022-12-23-cargo-semver-checks-today-and-in-2023/) that [semantic versioning in Rust is tricky and full of unexpected edge cases](/blog/2023-01-24-turning-rust-struct-to-enum-is-not-always-breaking/).

[My last post](/blog/2023-01-24-turning-rust-struct-to-enum-is-not-always-breaking/) mentioned that some Rust structs can be converted into enums without requiring a major version bump. It introduced a non-exhaustive struct called `Chameleon` that had no public fields, and claimed it was totally safe to turn it into an enum. But surely there was some sort of mistake, since syntax like `let Chameleon { .. } = value` would break if the `Chameleon` struct became an enum?

Yes, that statement would break.[^sn-1] And yet, this breaking change does not require a major version under Rust's semantic versioning rules!

How could a breaking change not be a semver-major change? Let's dig in and find out!

## All major changes are breaking, but not all breaking changes are major

There are two authoritative sources for semantic versioning in Rust: [the cargo semver reference](https://doc.rust-lang.org/cargo/reference/semver.html), and [the API evolution RFC](https://rust-lang.github.io/rfcs/1105-api-evolution.html).

Here's what the API evolution RFC says about breaking changes:
> What we will see is that in Rust today, almost any change is technically a breaking change. For example, given the way that globs currently work, adding any public item to a library can break its clients \[...\] But not all breaking changes are equal.<br><br>So, this RFC proposes that all major changes are breaking, but not all breaking changes are major.
>
> Source: [Rust API evolution RFC](https://rust-lang.github.io/rfcs/1105-api-evolution.html#detailed-design)

This feels ... strange. Running `cargo update` will by default update dependencies to the latest version in the same major version series, yet breaking changes are allowed without a new major version?

Ultimately, I feel[^sn-2] this is a case of Rust choosing pragmatism — and in my opinion, getting it right. I'll try to convince you of this in the rest of this post.

Let's take a look at two examples where breaking changes are _explicitly not_ semver-major.

## Adding a new public item is technically a breaking change

Let's pretend that in the below example, `first` and `second` are dependency crates of our library.

```rust
pub mod first {
    pub struct Foo;
}

pub mod second {
    // what happens if we uncomment this?
    // pub struct Foo;
}

use first::*;
use second::*;

fn process(foo: &Foo) {
    // do stuff with foo
}
```

Our library uses globs to import all public items from `first` and `second`. [This works fine!](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=1f87403328f8b811ce0572b032c8aa04)

Now imagine `second` adds some new functionality: uncomment its `pub struct Foo` line. This is a purely additive change: `second` can still do everything it could previously do, and has gained some new functionality via the new type `Foo`. Purely additive API changes are semver-minor, right?

Try [compiling the code](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=1f87403328f8b811ce0572b032c8aa04) after uncommenting that line, though. 💥 Oops! 💥

```rust_errors
error[E0659]: `Foo` is ambiguous
  --> src/lib.rs:13:18
   |
13 | fn process(foo: &Foo) {
   |                  ^^^ ambiguous name
   |
   = note: ambiguous because of multiple glob imports of a name in the same module

< ... fix suggestions omitted for brevity ... >
```

The code that depends on both `first` and `second` was broken by `second`'s purely additive change. Additive or not, it was _unquestionably_ a breaking change.[^sn-3]

If Rust semver demanded that all breaking changes must be semver-major, here are a few ways this could work:
- Option 1: Nearly all API additions are semver-major. This obviously doesn't seem right.
- Option 2: Glob imports are "last definition wins" (like in Python), or "first definition wins." I think this makes the problem worse, not better: now it's even less obvious which `Foo` is getting imported, and we're setting ourselves up for even worse compilation errors than otherwise.
- Option 3: Glob imports are removed from the language, since they play a part in causing this problem. That also means no more `prelude` modules designed for glob-importing, harming the ergonomics of awesome crates like `pyo3` and `futures`. This isn't good either.
- Option 4: When we write code like `&Foo`, a tool (say, `cargo` or `rustc`) immediately replaces `Foo` with its fully-qualified name: in this case, `first::Foo`. Glob imports serve only to tell that tool where to look while rewriting our code. This solution has way too many moving pieces, and doesn't feel particularly ergonomic, either.

 None of these options are good. Rust opted to go in another direction:
 - adding public items is semver-_minor_;
 - glob imports are discouraged, to minimize (but not _prevent_) breakage, and
 - maintainers of crates with `prelude` modules are encouraged to be mindful of what they add to the prelude, again to minimize but not prevent breakage.

A similar problem exists with trait methods: [implementing a public trait for any existing type is also technically breaking](https://github.com/rust-lang/rfcs/blob/master/text/1105-api-evolution.md#minor-change-implementing-any-non-fundamental-trait), and is also _explicitly defined_ as semver-minor _despite_ the breakage.[^sn-4]

## Breakage of patterns is not always semver-major

Pattern-matching on structs is always allowed in Rust, even if the struct being matched has no visible fields: [playground link](https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=5c8ad567eda2afde921b57769bc09c9b).
```rust
// say this is in some other crate
pub mod other {
    pub struct Foo(i64);
}

fn process(value: &other::Foo) {
    // Foo's field is not visible here!
    // This `let` does nothing useful:
    // - it can't extract any fields, and
    // - can't learn anything else about `value`.
    let Foo { .. } = value;
}
```

Some kinds of changes to `Foo` can cause `let Foo { .. } = value;` to break. [The RFC is unambiguous here](https://github.com/rust-lang/rfcs/blob/master/text/1105-api-evolution.md#minor-change-going-from-a-tuple-struct-with-all-private-fields-with-at-least-one-field-to-a-normal-struct-or-vice-versa): statements like `let Foo { .. } = value` serve no purpose other than to be broken if `Foo` changes, and its breakage is not sufficient to make this change semver-major.[^sn-5]

There are cases where the `Foo { .. }` pattern is useful to aid type inference, for example: `if let Some(x @ Foo { .. }) = x.downcast_ref()`.[^sn-6] However, those cases are specifically addressed in the RFC as well (original emphasis retained):
> For example, changes that may require occasional type annotations or use of UFCS to disambiguate are not automatically "major" changes. \[...\] any breakage in a minor release must be very "shallow": it must always be possible to locally fix the problem through some kind of disambiguation _that could have been done in advance_ (by using more explicit forms) or other annotation (like disabling a lint).
>
> Source: [Principles of the policy, Rust API evolution RFC](https://rust-lang.github.io/rfcs/1105-api-evolution.html#principles-of-the-policy)

This is why turning `Chameleon` from a struct into an enum [in the last post](/blog/2023-01-24-turning-rust-struct-to-enum-is-not-always-breaking/) did not require a new major version:
the only breakage that could happen was in type inference or in a statement that did not serve any purpose.
Barring some kind of exceptional situation (e.g., potential for ecosystem-wide breakage, definitely not the case here), the API evolution RFC _explicitly_ disqualifies both of those categories from triggering a semver-major change.

## Conclusion

Before reading this post, did you know that not all breaking changes require a new major version under Rust's semantic versioning principles?

Semver in Rust is hard for many reasons. There are a zillion strange ways to cause major breaking changes: [example](https://doc.rust-lang.org/cargo/reference/semver.html#struct-add-private-field-when-public), [another example](https://doc.rust-lang.org/cargo/reference/semver.html#possibly-breaking-change-adding-any-inherent-items). There's even "spooky action at a distance" where [adding a field to a type can cause traits to silently stop being implemented for that type](/blog/2022-08-25-toward-fearless-cargo-update/). And as we saw here, not all breaking changes are semver-major!

As if to prove my point, `cargo-semver-checks` [was recently broken](https://github.com/obi1kenobi/cargo-semver-checks/issues/317) by a dependency crate's semver-incompatible (and now [yanked](https://doc.rust-lang.org/cargo/commands/cargo-yank.html)) release.[^sn-7] Breaking semver is not shameful, and is not a sign of maintainers' carelessness, poor skill, or anything of the sort. It's just another language ergonomics problem solvable by better tooling.

This is the raison d'être for `cargo-semver-checks`.

[^sn-1]: Thanks to the alert readers on r/rust and Mastodon that pointed it out and even provided Rust Playground links! [This discussion](https://old.reddit.com/r/rust/comments/10k0eox/turning_a_rust_struct_into_an_enum_is_not_always/j5t213a/) was particularly nuanced and interesting.

[^sn-2]: I only recently got involved in Rust's semver story by working on `cargo-semver-checks`, so I wasn't part of the discussion or decisions in the API evolution RFC. This post is on my personal blog, not the Rust blog, so you're reading my own opinion and interpretation.

[^sn-3]: If you read the API evolution RFC's [section on adding public items](https://github.com/rust-lang/rfcs/blob/master/text/1105-api-evolution.md#minor-change-adding-new-public-items), you may have noticed that its example of breaking code by adding a public item is much shorter than the one here — and also that in today's Rust, that example isn't broken anymore! This is because Rust adopted another recommendation from that RFC: if a locally-defined item's name conflicts with a glob-imported name, the local item "wins" and shadows the other one instead of breaking with an ambiguous resolution error.

[^sn-4]: The [RFC states that](https://github.com/rust-lang/rfcs/blob/master/text/1105-api-evolution.md#minor-change-implementing-any-non-fundamental-trait) the breakage occurs if the breakage-causing trait already existed prior to being implemented. But in the RFC's example code, it's actually sufficient for the trait to make its way into crate B's scope. For example, crate B glob-importing of all of crate A's public items would cause the same breakage even if the conflicting trait did not previously exist.

[^sn-5]: Again, the [RFC's example for this case](https://github.com/rust-lang/rfcs/blob/master/text/1105-api-evolution.md#minor-change-going-from-a-tuple-struct-with-all-private-fields-with-at-least-one-field-to-a-normal-struct-or-vice-versa) doesn't quite work as written: Rust has evolved in the nearly 8 years since that RFC was written. But its point stands regardless.

[^sn-6]: Thanks to [this r/rust comment](https://www.reddit.com/r/rust/comments/10k0eox/comment/j5t213a/) for this excellent example!

[^sn-7]: This is why installing with <code class="nobr">cargo install --locked</code> is a good idea! Locked installs didn't break.

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