If you’ve poked around Rust projects, you’ve probably seen a build.rs file sitting next to Cargo.toml and wondered, “What is this for?” I know I have. I’d see it in projects, maybe cargo would mention it in build output, but I never really understood why it was there or what problem it solved.
Today I finally figured it out while working on my WordPress-like CMS project, and I want to share what I learned.
The Problem That Made Me Care
I’m building a web application called Rusty Word Smith. It’s a Cargo workspace with this structure:
rusty-word-smith/
├── Cargo.toml # Workspace root
├── templates/ # HTML templates
├── rws-core/ # Main server crate
│ ├── Cargo.toml
│ ├── configuration/ # Config files
│ └── src/
└── rws-service/ # Business logic
My application needs to find two things at runtime:
- Configuration files in
rws-core/configuration/ - Templates in
templates/(at workspace root)
Here’s where it got annoying. These commands behaved differently:
# From workspace root - works fine
cargo run
# From workspace root with package flag - crashes!
cargo run -p rws-core
# Error: configuration directory not found
# From inside rws-core - also crashes!
cd rws-core && cargo run
# Error: templates directory not found
Why? Because my code was using std::env::current_dir(), which gives you wherever the user ran the command from. That’s not what I needed - I needed to know where my project files actually live, regardless of where the user is standing.
Enter Build Scripts
Build scripts are special Rust files that run during compilation, before your actual code compiles. They can:
- Set compile-time environment variables
- Generate code
- Compile C/C++ dependencies
- Download external resources
- Basically anything you need to prepare for compilation
The key insight: build scripts run at compile time, not runtime. This means they can capture information about your project structure and embed it into your binary.
My Solution: Capture Workspace Paths at Compile Time
Here’s what I did. First, I created rws-core/build.rs:
use std::env;
use std::path::PathBuf;
fn main() {
// Get the directory where this Cargo.toml lives (rws-core/)
let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
// Get the workspace root (parent directory)
let workspace_root = manifest_dir.parent().unwrap();
// Tell rustc to set these as compile-time environment variables
println!("cargo:rustc-env=WORKSPACE_ROOT={}", workspace_root.display());
println!("cargo:rustc-env=CRATE_ROOT={}", manifest_dir.display());
}
Then I told Cargo about it in rws-core/Cargo.toml:
[package]
name = "rws-core"
version = "0.7.0"
edition = "2024"
build = "build.rs" # ← This line tells Cargo to run the build script
Now in my actual application code, I can do this:
use std::path::PathBuf;
fn find_config_dir() -> PathBuf {
// This reads the WORKSPACE_ROOT that was set at compile time
let workspace_root = PathBuf::from(env!("WORKSPACE_ROOT"));
workspace_root.join("rws-core/configuration")
}
fn find_templates_dir() -> PathBuf {
let workspace_root = PathBuf::from(env!("WORKSPACE_ROOT"));
workspace_root.join("templates")
}
And boom - it works from anywhere!
cargo run # ✓ Works
cargo run -p rws-core # ✓ Works
cd rws-core && cargo run # ✓ Works
The Magic: env!() vs std::env::var()
This confused me at first, so let me clarify:
std::env::var("VAR_NAME") - Runtime environment variable
- Reads from your shell when the program runs
- Changes based on where/how you run it
- Returns
Result<String, VarError>
// This reads from your shell environment when the program runs
let home = std::env::var("HOME").unwrap();
env!("VAR_NAME") - Compile-time environment variable
- Reads from build script during compilation
- Gets embedded into the binary as a constant
- Returns
&'static str(a constant string) - Fails compilation if the variable doesn’t exist
// This was set by build.rs during compilation
const WORKSPACE_ROOT: &str = env!("WORKSPACE_ROOT");
The env!() macro is evaluated at compile time and becomes a hardcoded string in your binary. It’s as if you wrote:
const WORKSPACE_ROOT: &str = "/home/jeff/Development/rusty-word-smith";
But the build script figured out the path automatically.
What Can Build Scripts Do?
Beyond setting environment variables, build scripts can:
1. Compile Native Dependencies
If your Rust project needs to link against C/C++ code:
// build.rs
fn main() {
cc::Build::new()
.file("src/native/helper.c")
.compile("helper");
}
2. Code Generation
Generate Rust code based on external data:
// build.rs
fn main() {
let sql_queries = std::fs::read_to_string("queries.sql").unwrap();
// Parse SQL and generate Rust structs...
let generated = generate_query_functions(&sql_queries);
std::fs::write("src/generated.rs", generated).unwrap();
}
3. Feature Detection
Check what’s available on the system:
// build.rs
fn main() {
if pkg_config::probe_library("openssl").is_ok() {
println!("cargo:rustc-cfg=has_openssl");
}
}
Then in your code:
#[cfg(has_openssl)]
fn secure_connection() { /* Use OpenSSL */ }
#[cfg(not(has_openssl))]
fn secure_connection() { /* Use rustls fallback */ }
4. Custom Build Flags
Pass information to rustc:
// build.rs
fn main() {
println!("cargo:rustc-link-search=native=/usr/local/lib");
println!("cargo:rustc-link-lib=static=mylib");
println!("cargo:rerun-if-changed=build.rs");
}
Important Build Script Commands
Build scripts communicate with Cargo through println! with special prefixes:
// Set a compile-time env var (accessible with env!())
println!("cargo:rustc-env=VAR_NAME=value");
// Add a library search path
println!("cargo:rustc-link-search=native=/path/to/libs");
// Link a library
println!("cargo:rustc-link-lib=static=mylib");
// Re-run build script if file changes
println!("cargo:rerun-if-changed=path/to/file");
// Tell Cargo about environment variables it should watch
println!("cargo:rerun-if-env-changed=CC");
// Add a cfg flag
println!("cargo:rustc-cfg=feature_name");
// Show warnings during build
println!("cargo:warning=This is a warning message");
When Should You Use a Build Script?
Use a build script when you need to:
✅ Capture compile-time information (like I did with workspace paths) ✅ Compile native dependencies (C/C++ code, system libraries) ✅ Generate code from external sources (protobuf, SQL, JSON schemas) ✅ Detect system capabilities (available libraries, CPU features) ✅ Embed version info (git commit hash, build timestamp)
Don’t use a build script when:
❌ Runtime configuration (use config files or env vars instead)
❌ Simple tasks (if include_str!() or include_bytes!() works, use those)
❌ Things that change often (build scripts only run when dependencies change)
Common Pitfalls
1. Build Scripts Run During Compilation
Your build script output isn’t shown when you cargo run - it only runs during build. To see it:
cargo clean
cargo build -vv # Very verbose
2. They Don’t Re-run Automatically
By default, build scripts only re-run when:
- Dependencies change
- The build script itself changes
Cargo.tomlchanges
Add this to make it re-run when specific files change:
println!("cargo:rerun-if-changed=config/settings.toml");
3. Build Scripts Must Be Fast
They run every time someone builds your project. Keep them quick:
- Cache expensive operations
- Don’t make network requests if avoidable
- Use
cargo:rerun-if-changedto avoid unnecessary re-runs
Real-World Example: Version Info
Here’s a practical example I see in many projects - embedding git info:
// build.rs
use std::process::Command;
fn main() {
// Get git commit hash
let output = Command::new("git")
.args(&["rev-parse", "HEAD"])
.output()
.unwrap();
let git_hash = String::from_utf8(output.stdout).unwrap();
println!("cargo:rustc-env=GIT_HASH={}", git_hash.trim());
// Get build timestamp
let timestamp = chrono::Utc::now().to_rfc3339();
println!("cargo:rustc-env=BUILD_TIME={}", timestamp);
}
Then in your code:
pub fn version_info() -> String {
format!(
"Version {} ({})\nBuilt: {}",
env!("CARGO_PKG_VERSION"),
&env!("GIT_HASH")[..8],
env!("BUILD_TIME")
)
}
Debugging Build Scripts
Build scripts are just Rust programs, so you can debug them:
# See verbose build script output
cargo build -vv
# Set env var to see all cargo messages
CARGO_LOG=cargo::core::compiler::fingerprint=info cargo build
# Add debug prints to your build.rs
eprintln!("DEBUG: workspace_root = {}", workspace_root.display());
Remember: println! goes to cargo, eprintln! goes to stderr (visible to you).
Conclusion
Build scripts are Rust’s way of letting you run code at compile time to prepare for building your project. They’re perfect for capturing information about your build environment, generating code, or compiling native dependencies.
The key mental model: build scripts run once during compilation to set up your build, not during runtime. They let you embed compile-time information into your binary.
For my use case - making a workspace project runnable from any directory - build scripts were the perfect solution. They captured where my project files live at compile time, so my runtime code always knows where to find them.
Next time you see a build.rs file, you’ll know exactly what it’s doing and why it’s there.
Further Reading
- The Cargo Book - Build Scripts
- The
builtcrate - Automates common build script tasks - The
cccrate - For compiling C/C++ dependencies
What’s your experience with build scripts? Have you found clever uses for them? Let me know in the comments or reach out on social media!
Comments