Going beyond build.rs: introducing cargo-px

TL;DR

cargo-px (cargo Power eXtensions) has just been released to crates.io!
It is a new cargo sub-command (i.e. you invoke it as cargo px [...]), designed to augment cargo's capabilities.

This first release is focused on code generation.
cargo supports build scripts via build.rs files, but, once you look close enough, you'll find out that they are not flexible enough to support all usecases. In particular, build.rs files fall short when you want to generate a whole crate (e.g. an API client starting from an OpenAPI spec or data models starting from a set of SQL queries and a database schema).

This is where cargo-px enters into the picture: it lets you specify a crate generator in the manifest of the soon-to-be-generated crate. It then transparently invokes the code generator when necessary, assuming you route your commands through cargo-px instead of cargo (i.e. invoke cargo px build rather cargo build).

Check out the rest of the article (and cargo-px's README) to learn more!

You can comment on r/rust.

Table of contents

Build script limitations

Let's get into the specifics: what are the issues with build.rs files?
I ran into two key limitations:

  1. Dependency management is cumbersome.
  2. Invoking most cargo commands in a build script will result in a deadlock.

We are going to dissect both of them in the next sections.

Dependencies

Let's look at a specific scenario: we want to generate some Rust code that should be included as a module of the library crate that "hosts" the build.rs file. To make the example easy to visualize, we can assume that the initial project layout looks like this:

# Before code generation
mylib/
  src/
    lib.rs (👈 empty)
  build.rs
  Cargo.toml

The build.rs script is going to create a new generated.rs file under the src directory1 and re-export its items from lib.rs:

//! mylib/src/lib.rs
mod generated;
// Re-export all items.
pub use generated::*;
# After code generation
mylib/
  src/
    lib.rs (⚠️ modified)
    generated.rs (👈 new file)
  build.rs
  Cargo.toml

So far so good, right?
Maybe! It depends on the code in generated.rs. Does it need any dependencies?

For any statement importing items from third-party crates (e.g. use serde::Serialize) there has to be a corresponding entry in the [dependencies] section of the Cargo.toml manifest of our project.
If that's not the case, cargo will fail to compile the project.

Unfortunately, build.rs can't help us here.
You can try to modify the Cargo.toml file to add the dependencies you need, but cargo is not going to regenerate the lockfile (Cargo.lock) after having executed the main function in your build.rs file. The code will fail to compile the first time, even though the Cargo.toml file has been modified to mention the required crates.
It will start working the second time you try to compile the project, because cargo will pick up the new manifest and re-run its dependency resolution algorithm.

That's not a great experience and it's in fact fairly uncommon for build.rs files to modify their Cargo.toml in any way.
That doesn't stop people from wanting to generate code that requires third-party dependencies though, which is why you find instructions like this one in the documentation of projects that rely heavily on code generation:

The code generated by [redacted] has a few dependencies that you need to [manually] import into your project's Cargo.toml.

It sucks.

It's equivalent to exposing your private modules and implementation details to the consumers of your project.
Do you want to migrate from chrono to time for managing timestamp columns? Now all your users need to manually swap one dependency for the other one in the Cargo.toml of their code-generated crates. The same happens if you want to bump the version of your dependencies due to breaking changes (e.g. from 0.2.x to 0.3.y)—perhaps it was an implementation detail, but now your users need to be (painfully) aware of it.

Invoking cargo

There is another corner case to be aware of when it comes to build scripts: invoking cargo commands from a build script can result in a deadlock.

cargo commands rely on a few different file locks to claim exclusivity before trying to modify some of the data stored in your filesystem. In particular, cargo will try to claim a file lock before executing any command that might modify your build cache (a.k.a. "target" directory).

Build scripts are invoked after cargo has successfully acquired a lock over your build cache.
If the build script tries to invoke a cargo command that modifies the build cache (e.g. cargo run or cargo doc or cargo build), it has no way of succeeding:

Boom, deadlock!

As a workaround, you can change the target directory for the cargo invocations coming from your build script. Different directory = different file lock = no deadlock 🎉

It's not free though—using a separate build cache implies that you won't be able to reuse any of the artefacts that have already been built, potentially having to recompile the entire dependency tree from scratch. Ouch.

The casus belli: pavex

As it happens, I found myself facing these challenges for pavex, a soon-to-be-launched Rust web framework that I've been working on for a few months.
The framework takes a declarative approach—you specify the requirements of your application using a Blueprint:

pub fn app_blueprint() -> Blueprint {
    let mut bp = Blueprint::new();
    bp.constructor(f!(crate::http_client), Lifecycle::Singleton);
    bp.constructor(f!(crate::extract_path), Lifecycle::RequestScoped);
    bp.constructor(f!(crate::logger), Lifecycle::Transient);
    bp.route(GET, "/home", f!(crate::stream_file));
    bp
}

pavex analyzes the Blueprint (i.e. who needs what inputs, lifecycles, etc) and performs compile-time dependency injection—i.e. it generates a library with all your dependencies correctly wired and a dedicated entrypoint that you can invoke to launch your API server.

That analysis step relies on the information contained in rustdoc's JSON output, which in turn forces us to invoke cargo doc (or cargo rustdoc) as part of the code generation step.
If that was not enough, the generated library has to depend on other crates! As a bare minimum, it must depend on the framework itself, but it will also have to import all the constructors and request handlers specified by the user, which might live in other local crates or come from a third-party registry.

I spent a few days torturing build.rs files. In the end, I threw in the towel: I don't think you can make it work using a build.rs file while providing a great developer experience.
I could have switched to a "proper" build system (e.g. buck2 or bazel), but forcing all users of pavex to say bye bye to cargo would be an extremely tall ask.
Sad, but not undeterred, I set out to build cargo-px.

cargo-px

cargo-px is a cargo sub-command.
You can install it via:

cargo install --locked cargo-px

It is designed as a cargo proxy2: instead of invoking cargo <CMD> <ARGS>, you go for cargo px <CMD> <ARGS>. For example, you go for cargo px build --all-features instead of cargo build --all-features.

cargo px examines your workspace.
If any of your crates needs to be generated, it will invoke the respective code generators before forwarding the command and its arguments to cargo.

This strategy is quite simple, but it solves both our problems:

But let's get to the specifics! How do you specify that a crate should be generated by cargo px?

It leverages the metadata section.
In the crate that you want to see generated, you fill in the [package.metadata.px.generate] section as follows:

[package]
name = "..."
version = "..."
# [...]

[package.metadata.px.generate]
# The generator is a binary in the current workspace. 
# It's the only generator type we support at the moment.
generator_type = "cargo_workspace_binary"
# The name of the binary.
generator_name = "bp"
# The arguments to be passed to the binary. 
# It can be omitted if there are no arguments.
generator_args = ["--quiet", "--profile", "optimised"]

cargo-px will detect the configuration and invoke cargo run --bin bp -- --quiet --profile="optimised" for you.
If there are multiple crates that need to be code-generated, cargo-px will invoke the respective code-generators in an order that takes into account the dependency graph (i.e. dependencies are always code-generated before their dependents).

cargo-px will also set two environment variables for the code generator:

That's all we have at the moment!
I'll keep experimenting with cargo-px in pavex, but I'm curious to find out if other projects are facing similar needs and might benefit from this solution.
I'd also be happy to engage with the cargo team if there is an interest in solving some of these problems upstream. cargo sub-commands are a great tool for experimentation, but standardisation is always preferable (where possible).

What about downstream users?

Actually, one more thing before you go!
Some of you may be asking: what about downstream users? Do they need to start using cargo-px as well?

It depends!
cargo-px is a tool designed for contributors to projects that need "whole crate" generation for one of the crates in their workspace.
If your crate is primarily a generator, then it might make sense to ask your users to rely on cargo-px if you are running into some of the limitations I mentioned above.

But the crate that gets generated by cargo-px is a "regular" crate—you can build it with cargo build and simply publish it to the registry.
If you are using cargo-px as part of your development process (i.e. to keep an API client up-to-date from a specification file), your users don't need to know. As far as they are concerned, they are downloading a normal crate from the registry (i.e. the output of code generation) and everything works as usual.

This is indeed one of the key differences between cargo-px and a build script: build scripts are executed on the user's machine. This is both a feature and a drawback:

Keep this in mind when evaluating your options for code generation.

You can comment on r/rust.


1

You aren't supposed to generate source files using build.rs—all the generated artefacts should live in the OUT_DIR folder. But it's a common pattern since there is no proper solution for source file generation, therefore I've chosen to "do it wrong" on purpose here.

2

It'd be cool if it were possible to alias cargo to cargo px in a specific workspace. I probably need to play around with shims or custom toolchains.