DEV Community

Cover image for High-cardinality values for build flags in Rust
Nicolas Fränkel
Nicolas Fränkel

Posted on • Originally published at blog.frankel.ch

High-cardinality values for build flags in Rust

While working on my demo on WebAssembly and Kubernetes, I wanted to create three different binaries based on the same code:

  • Native: compile the Rust code to regular native code as a baseline
  • Embed: compile to WebAssembly and use the WasmEdge runtime image as the base Docker image
  • Runtime: compile to WebAssembly, use a base scratch image as my base image, and set the runtime when running the code

The code itself is an HTTP server that offers a single endpoint. For the sake of the demo, I wanted it to return the flavour of the underlying image.

curl localhost:3000/get
Enter fullscreen mode Exit fullscreen mode
{"source": "native", "data": {}}
Enter fullscreen mode Exit fullscreen mode

The idea is to have a single codebase that I can compile to native or WebAssembly. I solved this requirement by using a cfg compile flag.

#[cfg(flavor = "native")]
const FLAVOR: &str = "native";

#[cfg(flavor = "embed")]
const FLAVOR: &str = "embed";

#[cfg(flavor = "runtime")]
const FLAVOR: &str = "runtime";

// Use FLAVOR later in the returned HTTP response
Enter fullscreen mode Exit fullscreen mode

In my case, I can live with three different values. But what if I had to deal with a high cardinality, say, fifteen? It would be quite a bore to define them manually. I searched for a solution and found Build Scripts.

Some packages need to compile third-party non-Rust code, for example C libraries. Other packages need to link to C libraries which can either be located on the system or possibly need to be built from source. Others still need facilities for functionality such as code generation before building (think parser generators).

Cargo does not aim to replace other tools that are well-optimized for these tasks, but it does integrate with them with custom build scripts. Placing a file named build.rs in the root of a package will cause Cargo to compile that script and execute it just before building the package.

-- Build Scripts

Let's try with a simple build.script at the root of the crate:

fn main() {
    println!("Hello from build script!");
}
Enter fullscreen mode Exit fullscreen mode
cargo build
Enter fullscreen mode Exit fullscreen mode
Compiling high-cardinality-cfg-compile v0.1.0 (/Users/nico/projects/private/high-cardinality-cfg-compile)
 Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.08s
Enter fullscreen mode Exit fullscreen mode

There isn't any log–nothing seems to happen. The documentation reveals the reason:

The output of the script is hidden from the terminal during normal compilation.
If you would like to see the output directly in your terminal, invoke Cargo as "very verbose" with the -vv flag.

Let's compile again with the verbose flag:

cargo build --vv
Enter fullscreen mode Exit fullscreen mode

The output is what we expect:

...
[high-cardinality-cfg-compile 0.1.0] Hello from build script!
...
Enter fullscreen mode Exit fullscreen mode

Now is the time to do something useful: I'll use the build script to replace the hard-coded constants above. For that, I'll generate a Rust code file that contains the value passed for flavor.

Note the build script runs before compilation. Hence, it doesn't have access to the cfg flags. Instead, I'll pass the value as a Cargo.toml package metadata key value.

[package.metadata]
flavor = "foobar"
Enter fullscreen mode Exit fullscreen mode

For our build script to read the Cargo.toml metadata, we need to add a build dependency:

[build-dependencies]
cargo_metadata = "0.19.1"
Enter fullscreen mode Exit fullscreen mode

The code is straightforward:

fn main() {
    let metadata = MetadataCommand::new()                                         //1
        .exec()
        .expect("Failed to fetch cargo metadata");

    let package = metadata.root_package().expect("No root package found");        //2

    let flavor = package                                                          //3
        .metadata
        .get("flavor")
        .and_then(|f| f.as_str())
        .expect("flavor is not set in Cargo.toml under [package.metadata]");

    let dest_path = Path::new("src").join("flavor.rs");                           //4

    fs::write(&dest_path, format!("pub const FLAVOR: &str = \"{}\";\n", flavor))  //5
        .expect("Failed to write flavor.rs");

    println!("cargo:rerun-if-changed=Cargo.toml");
    println!("cargo:warning=FLAVOR written to {}", dest_path.display());
}
Enter fullscreen mode Exit fullscreen mode
  1. Get the metadata
  2. Get the package where we want to create the new file
  3. Read the flavor value
  4. Reference src/flavor.rs
  5. Write pub const FLAVOR: &str = <flavor>

On the code side, we only need to use the FLAVOR const from the flavor module:

mod flavor;

fn main() {
    println!("Hello from flavor {}", flavor::FLAVOR);
}
Enter fullscreen mode Exit fullscreen mode

Running the program outputs the flavor configured in the Cargo.toml file:

cargo run
Enter fullscreen mode Exit fullscreen mode
Hello from flavor foobar
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this, I've shown how to use high-cardinality build parameters from the package metadata. I could have achieved the same with environment variables, but I wanted to learn about the metadata section of the Cargo.toml file.

The build.rs file is a handy trick for achieving goals that aren't possible with regular Cargo features.

To go further:


Originally published at A Java Geek on April 13th, 2025

Top comments (2)

Collapse
 
alexmario74 profile image
Mario Santini

In the demo you shown you can do the same with environment variables, but that's not the same as compile flags.

When you use compile flag you choose what part of the cose is compiled and what not.

A tipical example is the test code, that is always under a compile flag that is triggered when you run the command cargo test.

When you build your release the test code is not compiled and is not part of your binary.

On the other hand, if you drive the behaviour with environment variables, the code is always there. You change the environment variables value on runtime and the behaviour will change.

Just for the sake of clarity to distinguish the two options.

Collapse
 
nfrankel profile image
Nicolas Fränkel

That's my whole point: I want the value to be part of the binary, so I can show in my demo exactly its "flavor": native, runtime, or embedded.

If I used an environment variable, it would defeat the purpose.