We've already explored some of the dark corners of Rust semantic versioning on this blog:

I recently learned of a new semver hazard related to re-exporting, thanks to a tip from aDotInTheVoid. The last re-exporting hazard explored on this blog was breaking and major; this one is breaking but appears to not be major.

Re-exporting an enum with a type alias

Rust allows items to be re-exported via two mechanisms: by adding a new name via pub use, or by adding a type alias (also called "typedef") via pub type. These two approaches are similar, but not identical — a previous post already explored a distinction between them that can cause an unexpected major breaking change.

Say our example crate has the following code:

// in `example/src/lib.rs`:
pub enum Foo {
    A,
    B,
}

A subsequent version of example updates the code to this:

// in example/src/lib.rs
mod inner {
    pub enum RenamedFoo {
        A,
        B,
    }
}

pub type Foo = inner::RenamedFoo;

At first glance, this seems like it should behave identically: example::Foo is still an enum with the same variants as before, and example::Foo::A syntax works just fine.

But consider the following code in a downstream crate:

// in downstream/src/lib.rs
fn produce_foo(x: i64) -> example::Foo {
    use example::Foo::*;
    if x > 5 {
        A
    } else {
        B
    }
}

Surprsingly, this code is now broken:

error[E0432]: unresolved import `example::Foo`
  --> src/lib.rs:15:9
   |
15 |     use example::Foo::*;
   |                  ^^^ `Foo` is a type alias, not a module

Replacing the enum with a type alias is a breaking change!

But is it a major change?

Some Rust breaking changes don't require a major version. Does this one?

I believe it does not.

The Rust API evolution RFC says the following about breaking changes that are not semver-major: (emphasis in original)

That means that 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).

In this case, the code could have been altered in advance in a way that would have avoided the breakage:

// in downstream/src/lib.rs
fn produce_foo(x: i64) -> example::Foo {
    if x > 5 {
        example::Foo::A
    } else {
        example::Foo::B
    }
}

Replacing the glob with direct imports doesn't solve the problem

The last time we saw a breaking change related to imports, replacing the glob import with direct imports of the contained items avoided the problem. This time we are not so lucky.

This code:

// in downstream/src/lib.rs
fn produce_foo(x: i64) -> example::Foo {
    use example::Foo::{A, B};
    if x > 5 {
        A
    } else {
        B
    }
}

produces a similar error as before:

error[E0432]: unresolved import `Foo`
  --> src/lib.rs:15:9
   |
15 |     use example::Foo::{A, B};
   |                  ^^^ `Foo` is a type alias, not a module

To avoid this breaking change, we can import the enum (or its type alias) but we must not import the variants.

Is this working as intended?

Whether this behavior is expected and intentional, or an ergonomics issue to be fixed, is an open question at the moment. A future Rust edition may make pub use and pub type equivalent. In the meantime, this is yet another breaking change that does not require a new major version.

Thanks to aDotInTheVoid for reviewing a draft of this post. Any mistakes are mine alone.