Crabrix blog
Cargo build.rs: generated code and reliable rebuilds
Build a complete Cargo code generator with tracked file and environment inputs. Understand OUT_DIR, host versus target, and reliable rebuild tests.
· 12 min read
Crabrix blog
Build a complete Cargo code generator with tracked file and environment inputs. Understand OUT_DIR, host versus target, and reliable rebuild tests.
· 12 min read
Generated code can remove tedious repetition, but it also adds a second program whose behavior affects your application. A small configuration file becomes Rust source, a native library probe changes linker arguments, or a schema produces a set of types. When the result looks stale, the defect may be in the generator's declared inputs rather than in the generated function. Understanding that boundary makes build failures much easier to diagnose.
This guide builds a complete, dependency-free desktop Cargo project. Its build script reads one greeting file, optionally changes the greeting's case, and writes one Rust constant. The important result is a visible input contract that we can test. Crabrix's bundled local compiler does not execute Cargo build scripts or procedural macros: inspect this example in the app, but run the Cargo commands on a supported desktop toolchain.

Before creating a build script, ask whether an ordinary constant, checked-in source file, or runtime configuration would be simpler. A build script makes sense when the compiled program genuinely needs a generated artifact or information established during the build. It is a poor substitute for loading a user's changing preferences at runtime. Those preferences would require recompilation whenever they changed, which usually conflicts with the product's behavior.
Our greeting is deliberately tiny so the generation mechanics stay visible. A real project might generate a lookup table from a reviewed specification using the same structure. Keep the generator narrow enough that someone can list every input without reading the entire repository. If its output depends on the current time, an undocumented environment variable, or an unversioned network response, that list is incomplete even when a clean build happens to succeed.
Create a new directory containing Cargo.toml, a root build.rs, settings/greeting.txt, and src/main.rs. The greeting file should initially contain Hello, Rust! followed by a newline. Cargo discovers the conventional build script automatically. No external crate is needed, and the manifest below intentionally has no dependency section: an example about generation should not obscure its behavior behind an unrelated download.
The package uses the Rust 2024 edition. The modern cargo:: instruction syntax used later requires Cargo from Rust 1.77 or newer, so an edition-capable current stable toolchain is sufficient. If you maintain a package with an older supported compiler, choose syntax and edition consistently with that policy. A build script is part of your supported toolchain surface, just like the library code that users import.
[package]
name = "build_contract_demo"
version = "0.1.0"
edition = "2024"The build script below announces two relevant inputs: the greeting file and a caller-controlled environment variable named GREETING_STYLE. It accepts only plain or upper, rejects an empty or multiline greeting, and writes the generated constant below OUT_DIR. The generated source uses Rust's debug representation of the string, so quotation marks and backslashes in the greeting remain escaped instead of accidentally becoming source syntax.
This is a complete build.rs, including error propagation. A missing file, invalid setting, or failed write produces a failed build with a useful explanation. It does not silently substitute an older greeting. The generator uses ASCII case conversion intentionally; this keeps the example's transformation small and explicit. A product that needs language-sensitive text conversion should define that requirement separately instead of assuming every uppercase operation has the same semantics.
use std::{env, error::Error, fs, path::PathBuf};
fn main() -> Result<(), Box<dyn Error>> {
println!("cargo::rerun-if-changed=settings/greeting.txt");
println!("cargo::rerun-if-env-changed=GREETING_STYLE");
let input = fs::read_to_string("settings/greeting.txt")?;
let greeting = input.strip_suffix("\r\n").or_else(|| input.strip_suffix('\n')).unwrap_or(&input);
if greeting.is_empty() || greeting.contains(['\r', '\n']) {
return Err("greeting must be one nonempty line".into());
}
let style = match env::var("GREETING_STYLE") {
Ok(value) => value,
Err(env::VarError::NotPresent) => "plain".into(),
Err(error) => return Err(error.into()),
};
let greeting = match style.as_str() {
"plain" => greeting.to_owned(),
"upper" => greeting.to_ascii_uppercase(),
_ => return Err("GREETING_STYLE must be plain or upper".into()),
};
let output = PathBuf::from(env::var_os("OUT_DIR").ok_or("OUT_DIR missing")?);
let source = format!("pub const GREETING: &str = {greeting:?};\n");
fs::write(output.join("generated.rs"), source)?;
Ok(())
}Sources: Cargo reference: build scripts
The application includes exactly the file the generator writes. env!("OUT_DIR") expands while compiling this package, and concat! constructs the generated file's path. The included item is an ordinary constant once parsed. From the application's point of view, printing it is no different from printing a constant written directly in main.rs; generation does not turn it into a value that can change after compilation.
Keep the include location easy to find. In a larger crate, a private generated module with a small hand-written public wrapper is often easier to maintain than scattering generated items throughout several modules. That wrapper can validate assumptions and provide documentation without editing a file that will be overwritten. Generated source is an implementation artifact; the public interface should remain something a reader can understand and review deliberately.
include!(concat!(env!("OUT_DIR"), "/generated.rs"));
fn main() {
println!("{GREETING}");
}Cargo compiles and runs the build script on the build host. The generated source is then consumed while compiling the package for its selected target. Those can be different platforms during cross-compilation. A script running on a 64-bit desktop must not assume that its own pointer width, operating system, or available system libraries describe the program being produced for another target.
The diagram separates these stages because many confusing cross-build defects begin by collapsing them. A cfg! expression inside build.rs describes that script's compilation environment. When the script needs information about the package's target configuration, Cargo exposes target-related environment variables such as CARGO_CFG_TARGET_ARCH. Choose the information source that answers the actual question. An executable discovered on the host is not automatically a library that can be linked into the target.

Run the project with GREETING_STYLE set explicitly to plain. It should print Hello, Rust!. Change only settings/greeting.txt to Hello, Crabrix!, run it again, and check that the new greeting appears. This is more meaningful than repeatedly cleaning the target directory: the defect we want to catch is a normal incremental build failing to notice a relevant input change.
Also test a greeting containing quotation marks and a backslash. The resulting program should print those characters exactly, without a Rust parse error or extra escape characters in its output. That check exercises the boundary between data and generated syntax. If your real generator emits identifiers, expressions, or types instead of string literals, each category needs its own validation rule; string escaping cannot safely validate arbitrary Rust source.
GREETING_STYLE=plain cargo run --quiet
# Expected: Hello, Rust!
# Edit settings/greeting.txt to contain Hello, Crabrix!
GREETING_STYLE=plain cargo run --quiet
# Expected: Hello, Crabrix!Without editing the greeting file again, run with GREETING_STYLE=upper. The program should now print HELLO, CRABRIX!. Then switch back to plain and verify the original case returns. These transitions exercise the declared environment input. A generator that reads the variable but forgets to track it can appear correct on a fresh checkout while serving stale output during ordinary development.
The shell commands use POSIX assignment syntax; PowerShell users can set the corresponding process environment variable before running Cargo. The variable belongs to this example, rather than to Cargo's general configuration. Avoid reusing unrelated system variables to control generation because that creates accidental dependencies on a developer's machine. If the output depends on several settings, document their allowed values and which defaults become part of a distributed build.
GREETING_STYLE=upper cargo run --quiet
# Expected: HELLO, CRABRIX!
GREETING_STYLE=plain cargo run --quiet
# Expected: Hello, Crabrix!Temporarily empty the greeting file and run the project. Cargo should stop because the build script returned an error. Restore the input and verify that the next invocation succeeds. Repeat with an unsupported style. These failures are useful tests: they prove that invalid inputs cannot quietly produce a successful application containing an old generated value that happens to remain on disk.
Cargo does not promise to clear OUT_DIR between build-script runs. A real generator that produces multiple files must manage its own obsolete outputs and ensure that a failed partial run cannot be mistaken for a complete new result. For a multi-file format, consider generating into a staging directory and publishing a manifest only when all outputs are valid. The exact strategy depends on which files later compilation consumes and what recovery must preserve.
Once a script emits explicit rerun instructions, its input list becomes a maintenance obligation. If tomorrow's generator reads a second template, add that template to the tracked inputs in the same change. Tracking a directory can be appropriate when its contents genuinely define the input set, but tracking the whole repository just to avoid thinking about dependencies makes unnecessary rebuilds harder to explain.
Distinguish inputs from outputs. Watching the generated file as though it were an upstream specification can create confusing rebuild behavior, and writing generated files back into the source tree can dirty a checkout unexpectedly. Keep reviewed specifications under version control and disposable output under the directory Cargo provides. If generated code must be committed for distribution reasons, design an explicit regeneration command and a consistency check for that separate workflow.
For this example, the same greeting bytes and style produce the same generated.rs bytes. We can verify that without claiming the entire executable is bit-for-bit reproducible across machines. Linkers, compiler versions, paths, native inputs, and build configuration may affect the final artifact. A precise claim about the generator is valuable because it identifies one boundary whose behavior can be checked independently.
Avoid embedding the wall clock, a random identifier, or the absolute temporary output directory merely to decorate generated source. Such values create changes unrelated to the specification and make meaningful differences harder to inspect. If a release needs provenance, record it in a deliberate metadata channel with a documented policy. A timestamp may be appropriate there, but it should not accidentally invalidate every compilation of otherwise identical generated items.
When a change does not appear, first confirm which package, working directory, environment, and target you actually built. Use Cargo's verbose output to understand whether a build script ran and which compiler invocation followed it. Build-script output is especially useful when a script emits target configuration or linker instructions that are not visible in ordinary application source.
A clean build can help distinguish stale incremental state from a deterministic generator bug, but cleaning first destroys useful evidence. Capture the failing inputs and exact command before removing anything. If a clean build fixes the symptom, still investigate the missing dependency declaration. Otherwise the same failure will return when someone repeats the original sequence of edits, which is exactly the sequence a reliable incremental build should support.
Sources: Cargo Book: build script examples
A build script executes code with the permissions of the process running Cargo. Even a small generator can read files or launch other programs if written to do so. Review it as executable project code, including scripts in dependencies, and keep unnecessary network access out of the transformation. A lockfile identifies dependency resolutions; it does not by itself describe every file, tool, or service a build script might consult.
For native-library integration, verify both discovery and target compatibility. Finding a host library or command is only the beginning of that work. Prefer an explicit error describing the missing target prerequisite over a guessed path that produces a mysterious linker failure later. Our text generator avoids those dependencies entirely, making it a useful baseline before introducing a C compiler, schema compiler, or platform-specific SDK.
A useful generator test separates the input fixture from Cargo's surrounding machinery. For our project, compare the generated source after two builds with identical input bytes and style. Then change one input at a time and verify the expected constant changes. Keep the fixture small enough that the generated file can be inspected without a code browser or a large snapshot diff.
Do not compare an arbitrary file from an old output directory. Cargo can create different output directories for different build configurations, and several generated files may remain beneath target. Identify the output belonging to the invocation under test. A test that accidentally reads a previous successful artifact can falsely report that generation worked even after the current script failed before producing anything.
This small generator writes its file whenever it runs. A larger generator may compare the new bytes with the existing file and avoid replacing an identical artifact. That can reduce unnecessary downstream work in some designs, but it adds a read and another error-handling path. Introduce the optimization only when there is a concrete reason and preserve correctness when the file is absent or unreadable.
Keep byte equality separate from semantic equality. Two generated files that happen to compile to equivalent behavior may differ in ordering, comments, or formatting. Prefer a stable generation order and format so meaningful source changes remain visible. If you do add write-if-changed behavior, test both the changed-output path and a missing-output rebuild; otherwise a convenience optimization can become another source of stale artifacts.
When a specification change creates new public items, review both the specification and the interface consumers will see. A source generator can produce syntactically valid Rust while still creating an awkward or incompatible API. Names, visibility, feature conditions, and documentation remain design decisions even when a program emits the corresponding tokens.
For a private greeting constant, that review is trivial. For generated protocol types or tables used by several crates, it deserves a small representative diff and focused consumer tests. Establish whether generated output is checked in, inspected during release, or validated through fixtures. The review mechanism can vary, but there should be an answer before a large generated change reaches users who depend on its interface.
A missing setting and a malformed setting should not accidentally mean the same thing. For this project's optional style, absence selects the documented default; an unknown value produces an error. If an environment value cannot be represented as the expected text, report it instead of silently pretending it was absent. This makes unusual machine configuration diagnosable rather than a hidden source of different output.
Apply the same principle to paths and tool locations in more complex generators. Decide whether a relative path is interpreted from the package directory or another explicit base, and avoid exposing irrelevant absolute paths in generated public source. A build configuration becomes much easier to reproduce when its values have defined types, defaults, validation rules, and a clear place where errors are reported.
Document the four project files, the accepted style values, the exact desktop command, and the expected greeting after each input transition. Keep a small fixture containing escaped characters and a failing fixture containing an empty or multiline greeting. A new developer should be able to reproduce all of those outcomes without guessing which environment setting made your own machine succeed.
When the generator grows, preserve that same discipline: enumerate inputs, isolate outputs, distinguish host execution from target compilation, and test ordinary rebuild transitions. The amount of generated source matters less than the clarity of those rules. A useful build script is a predictable stage in the project, with observable failures and a limited responsibility that can survive both a clean checkout and a long afternoon of incremental edits.