build.rs
A file named build.rs beside the manifest is compiled and run before the package, and its standard output is a list of instructions to cargo. That is the hook for a generated table, a version read from a file, a native library found on the system, or a constant that must come from the build machine.
This lesson writes four scripts: one that exports an environment variable, one that reruns when a file changes, one that generates Rust source into OUT_DIR, and one that fails on purpose.
A constant from the build
The script prints cargo:rustc-env=NAME=value, and cargo passes that variable to the compiler of the package. The program reads it with env!, which is resolved at compile time and produces a plain literal in the binary.
# build.rs is a normal Rust program with a main function
fn main() {
let target = std::env::var("TARGET").unwrap();
println!("cargo:rustc-env=BUILD_TARGET={target}");
println!("cargo:rerun-if-changed=build.rs");
}
fn main() {
println!("{}", env!("BUILD_TARGET"));
}
vi build.rs
cargo run
Compiling built v0.1.0 (/tmp/rw7/L083/built)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 3.36s
Running `target/debug/built`
x86_64-alpine-linux-musl
The variable TARGET is one of the ones cargo sets for the script, and the triple it printed is the target of this machine. The second line tells cargo that the script itself is an input, so editing it runs the script again.
What makes the script run again
Cargo caches the result of the script and reruns it only when an input it was told about changed. Without cargo:rerun-if-changed the default is the whole package, which is safe and slow; naming the files keeps a rebuild quick and is the reason a version file can be read at build time.
The script below reads version.txt and exports its content. The program prints the version, and the three runs show the cache: a build after the first, then a build after the file changed.
use std::fs;
fn main() {
let version = fs::read_to_string("version.txt").unwrap_or_else(|_| String::from("0.0.0"));
println!("cargo:rustc-env=APP_VERSION={}", version.trim());
println!("cargo:rerun-if-changed=version.txt");
}
fn main() {
println!("version {}", env!("APP_VERSION"));
}
echo "1.4.0" > version.txt
cargo run
cargo run
echo "1.5.0" > version.txt
cargo run
Compiling built v0.1.0 (/tmp/rw7/L083/built)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 2.52s
Running `target/debug/built`
version 1.4.0
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/built`
version 1.4.0
Compiling built v0.1.0 (/tmp/rw7/L083/built)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.40s
Running `target/debug/built`
version 1.5.0
The second run did not compile, and the third did, because the file the script named had changed. A script that reads a file without naming it is a bug that appears as a stale version in a release build, which is the classic use of this instruction.
Code generated in OUT_DIR
The variable OUT_DIR points at a folder inside target that belongs to this package and this build. A script writes a Rust file there and the program includes it with include!, which inserts the file at compile time as if it had been typed in place.
The script writes a table of three numbers, and the program sums the table at run time. The generated file is not in the source tree, so no one edits it by hand and no linter reads it.
use std::env;
use std::fs;
use std::path::PathBuf;
fn main() {
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
fs::write(out.join("table.rs"), "pub const TABLE: [u32; 3] = [2, 4, 8];\n").unwrap();
println!("cargo:rerun-if-changed=build.rs");
}
include!(concat!(env!("OUT_DIR"), "/table.rs"));
fn main() {
println!("{}", TABLE.iter().sum::<u32>());
}
cargo run
Compiling generated v0.1.0 (/tmp/rw7/L083/generated)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.24s
Running `target/debug/generated`
14
concat! joins the folder and the file name at compile time, and include! takes a literal path, which is why the two macros are written together. Generating a table this way keeps a large array out of the source and lets the script compute it from data the build machine has.
A build script that fails
A script that exits with an error stops the build before the package is compiled. Cargo reports the package, the exit status and the standard error of the script, so a panic message is the place to say what is missing.
The script requires a configuration file and panics when it is absent. The build prints the panic.
fn main() {
let missing = std::fs::read_to_string("config.toml");
if missing.is_err() {
panic!("config.toml is required");
}
}
cargo build
Compiling broken v0.1.0 (/tmp/rw7/L083/broken)
error: failed to run custom build command for `broken v0.1.0 (/tmp/rw7/L083/broken)`
Caused by:
process didn't exit successfully: `/tmp/rw7/L083/broken/target/debug/build/broken-1b367c6ddd975e0a/build-script-build` (exit status: 101)
--- stderr
thread 'main' (4269) panicked at build.rs:4:9:
config.toml is required
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
The path of the compiled script and the hash in its name are build artefacts, and the panic line names the file and the column. A script that must fail gracefully prints the message and calls std::process::exit(1), which gives cargo the same stop without a panic in the log. The next lesson changes the profile that compiles this script.
Exercises
- Export the git commit hash from a build script and print it from the program.
- Generate a lookup table of ten values into OUT_DIR and include it.
- Remove rerun-if-changed from a script and watch when cargo reruns it.
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
When does cargo run
build.rs?Answer
Before it compiles the package
The script prepares the inputs of the package, so it runs first.
-
How does a build script pass an environment variable to the program?
Answer
It prints
cargo:rustc-env=NAME=valueCargo reads the directive and gives the variable to the compiler of the package.
-
What does
cargo:rerun-if-changeddo?Answer
It makes the script run again when that file changes
The instruction narrows the inputs that invalidate the cached script result.
-
What does
OUT_DIRpoint at?Answer
A folder in target that belongs to this package and build
Generated files live there, and include! reads them at compile time.
-
What does this program print?
use std::env; fn main() { let out = env::var("HOME"); println!("{}", out.is_ok()); }Answer
true
The variable is set in the environment of the process, so the result is Ok.
-
What does this program print?
fn main() { let text = "1.5.0"; let parts: Vec<&str> = text.split('.').collect(); println!("{}", parts.len()); }Answer
3
Two dots divide the version into major, minor and patch.
-
What does this program print?
fn main() { let table = [2u32, 4, 8]; let sum: u32 = table.iter().sum(); println!("{}", sum); }Answer
14
The three values add up to fourteen.
-
What does this program print?
fn main() { let name = option_env!("BUILD_TARGET"); println!("{}", name.unwrap_or("unknown")); }Answer
unknown
option_env! yields None for an unset variable, and unwrap_or supplies the default.
-
What is the first line of the compiler output for this program?
fn main() { println!("{}", env!("BUILD_TARGET")); }Answer
error: environment variable
BUILD_TARGETnot defined at compile timeThe macro needs the variable at compile time, and nothing set it for this build.
-
What is the first line of the compiler output for this program?
fn main() { let path = "/table.rs"; let text = include!(concat!(path)); println!("{text}"); }Answer
error: expected a literal
concat! takes literals, so a variable cannot be joined into an include path.
The code on this page was built with rustc 1.96.1 and cargo 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev