I recently embarked on a quest: revamp the cargo-semver-checks import-handling system so that moving and re-exporting an item stops being incorrectly flagged as a major breaking change. This is how crate authors can reorganize or rename items: just re-export the items in the original location under their original names, and downstream users shouldn't notice.

Sounds simple, right?

I thought so too.

Then I started coming up with strange edge cases, each more cursed than the last. But cargo-semver-checks has to handle them correctly, cursed or not! So off I went down yet another Rust rabbit hole...

After a few weeks of adventuring, I have emerged, seemingly Only time will tell, won't it? It's looking good so far, though I did originally miss at least one edge case. victorious! Let me now regale you with some tales from my travels. Although 'travails' might be a more accurate word choice.

Moving and re-exporting an item

Say we have a crate called example containing the following code:

pub struct Foo {}

Rust modules can include other modules' items in their own API: they can use pub use to re-export the other module's item. Our example crate's users won't notice if we do the following change:

pub(crate) mod inner {
    pub struct Foo {}
}

// Users of this crate can import this
// as `example::Foo` just as before.
pub use inner::Foo;

The re-export is allowed to rename the item:

pub(crate) mod inner {
    // Renamed; used to be called `Foo`.
    pub struct Bar {}
}

// Users of this crate can import this
// as `example::Foo` just as before.
pub use inner::Bar as Foo;

Re-exports can also use a "glob" pattern to select and re-export all public items in the target module:

pub(crate) mod inner {
    pub struct Foo {}
}

// Users of this crate can import
// `example::Foo` just as before,
// because all public items in `inner`
// are selected for re-export.
pub use inner::*;

So far so good! But now the fun begins...

Using a type alias to re-export

Rust's pub type statement allows us to define a type alias, an "equivalent name" for some Rust type.

This isn't the same thing as pub use, but in many cases Ominous music starts... can be used equivalently to pub use. For example, we could use a pub type to accomplish the same renaming that earlier used a pub use by doing the following:

pub(crate) mod inner {
    // Renamed; used to be called `Foo`.
    pub struct Bar {}
}

// Users of this crate can import this
// as `example::Foo` just as before.
pub type Foo = inner::Bar;

Many crates use pub type instead of pub use to re-export items. Some crates actively encourage using pub type instead of pub use, for reasons that are out of scope here. They produce valid Rust code, and cargo-semver-checks should support that code.

Using pub type as if it were a pub use is fine ... most of the time. But every so often, it's a ✨ major breaking change! ✨

Accidental major breaking change via pub type

Say our original crate had defined Foo to be a unit struct instead of a plain struct:

// Previously, `Foo` was defined
// with curly braces like so:
// `pub struct Foo {}`
pub struct Foo;

We now make the same completely innocuous-seeming change as before:

pub(crate) mod inner {
    // Renamed; used to be called `Foo`.
    // This one is also a unit struct:
    // no curly braces this time.
    pub struct Bar;
}

// Is this `Foo` equivalent to the old one?
pub type Foo = inner::Bar;

💥 Oops! 💥 What happened?

The idiomatic way to construct a unit struct is to just name it: let _ = Foo. But that doesn't work if Foo is a type alias to a unit struct!

error[E0423]: expected value, found type alias `Foo`
  < ... >
  = note: can't use a type alias as a constructor

The same thing happens if Foo is a tuple struct: Foo(42) works fine on the struct itself but doesn't work on the type alias. There's an open issue suggesting that in a future Rust edition, this kind of pub type may become completely equivalent to a pub use. In that case, this would no longer be a breaking change. Thanks to this r/rust comment for the link!

This seems like exactly the sort of thing one would find right after publishing a new version, when some poor user opens an issue saying their build is now broken.

How cargo-semver-checks handles this

The immediate goal (achieved in v0.17) was to stop flagging re-exports as major breaking changes. In our philosophy, it's better to miss a real semver-major change than to falsely report a breaking change. Nobody likes it when CI turns red due to a tool being wrong.

Prior to 0.17, cargo-semver-checks would simply miss many re-exports and would claim that the type had been entirely removed from the API. That lint was obviously not correct — not even in the case of a pub type of a unit or tuple struct as in the example above.

We plan to add a new lint to catch this case and explain the specific problem in detail, since we expect most users would be quite surprised to see a major breaking change reported there.

Conclusion

Did you notice the major breaking change above? Probably not, right? You are not alone!

I only found it because someone pointed me in the correct general direction.

Semver in Rust is hard to get right, example number zillion and one, right here. This is why we use automated tools.

And I haven't even gotten a chance to bring up any truly cursed Rust examples of re-exports yet. I wrote approximately 30 test crates while implementing this. Nearly all of them are more cursed than any code in this post. They are split between cargo-semver-checks itself and its dependency crate trustfall-rustdoc-adapter, which allows cargo-semver-checks to declaratively query rustdoc JSON via the Trustfall query engine. If my adventure into Rust re-exports is like Frodo's journey in Lord of the Rings, then this post covers Frodo leaving his front yard.

If you liked this post, let me know! Then I'll write up the rest of the trip to Mount Doom.