Module 2: Setup and Your First Project

Rust Setup and Cargo

Learning objectives

  • Explain the core mental model behind Rust Setup and Cargo
  • Apply Rust Setup and Cargo within Setup and Your First Project
  • Identify important boundaries, trade-offs, and failure modes
  • Produce concrete evidence from the practice exercise

Related: Rust What and Why | Rust Modules and Crates | Rust Testing | Go Setup and Toolchain | Rust Index


Why Rust Ships a Build System At All

C and C++ deliberately do not define a build system, a package manager, or a project layout. The consequence is that every C++ project invents its own: Make, CMake, Bazel, Meson, autotools, plus vcpkg or Conan or a third_party/ directory of vendored source. Onboarding to a C++ codebase means learning that project's bespoke build archaeology before you can compile a line.

Rust made the opposite call: one official toolchain manager (rustup), one official build tool and package manager (cargo), one project layout, one manifest format. This is not a convenience feature — it is a deliberate ecosystem decision. Because everyone uses Cargo, every crate on crates.io builds the same way, cargo test works in every repository, and documentation is generated uniformly.

The cost: you lose the escape hatches. Integrating Rust into an existing Make- or Bazel-driven build (very common in EDA and firmware flows) means either driving Cargo from your Makefile or teaching your build system to call rustc directly, and the latter is painful. See the interop section below.

Verification analogy: this is the difference between a project where every engineer writes their own compile script and a project standardized on a single Makefile convention plus a common filelist format. The standardization is worth more than any individual script's flexibility.


The Layered Toolchain

Understanding which tool does what prevents most setup confusion.

   ┌──────────────────────────────────────────────────────────┐
   │ rustup            toolchain manager                      │
   │   installs/switches: stable | beta | nightly             │
   │   installs targets: x86_64-unknown-linux-gnu,            │
   │                     thumbv7em-none-eabihf, wasm32-...    │
   └──────────────────────┬───────────────────────────────────┘
                          │ provides
                          ▼
   ┌──────────────────────────────────────────────────────────┐
   │ cargo             build system + package manager         │
   │   resolves deps → Cargo.lock                             │
   │   orchestrates:  build | test | run | doc | bench        │
   └──────────────────────┬───────────────────────────────────┘
                          │ invokes, once per crate
                          ▼
   ┌──────────────────────────────────────────────────────────┐
   │ rustc             the compiler                           │
   │   borrow check → MIR → LLVM IR → object code             │
   └──────────────────────────────────────────────────────────┘

   alongside:  rustfmt (format)   clippy (lint)   rust-analyzer (LSP)

The key structural fact: rustc compiles one crate at a time, not one file at a time. A crate is the compilation unit. This is unlike C, where each .c is compiled separately and linked; it is more like compiling an entire SystemVerilog package as one unit. It is also why rustc can do whole-crate optimization and cross-function borrow analysis without a link-time step. See Rust Modules and Crates.


Installation

# One command, all platforms. Installs rustup, cargo, rustc, stdlib docs.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# Verify
rustc --version      # rustc 1.8x.x (hash date)
cargo --version
rustup show          # active toolchain and installed targets

Do not install Rust from apt/yum on a machine where you will do real work — distro packages lag badly and cannot switch toolchains. rustup installs into ~/.cargo and ~/.rustup, needs no root, and is trivially removable (rustup self uninstall).

Essential components:

rustup component add clippy rustfmt rust-src
rustup component add rust-analyzer     # or install via your editor

Toolchain management, which you will need eventually:

rustup update                              # update everything
rustup toolchain install nightly           # for unstable features / miri
rustup default stable                      # set global default
rustup override set nightly                # per-directory override
rustup target add thumbv7em-none-eabihf    # cross-compile to Cortex-M4F

Pin the toolchain per project. Create rust-toolchain.toml at the repo root:

[toolchain]
channel = "1.83.0"
components = ["clippy", "rustfmt"]
targets = ["wasm32-unknown-unknown"]

Now every engineer and every CI runner uses the identical compiler. This eliminates the entire class of "works on my machine" build failures — the same reason you pin your simulator version in a regression environment rather than using whatever is on $PATH.


The First Project, and What Each File Is For

cargo new sim_tools        # binary crate
cargo new --lib vcd_parser # library crate
cd sim_tools
sim_tools/
├── Cargo.toml        ← manifest: name, version, edition, dependencies
├── Cargo.lock        ← exact resolved versions (commit this for binaries)
├── .gitignore        ← generated; ignores /target
└── src/
    └── main.rs       ← crate root for a binary
# Cargo.toml
[package]
name = "sim_tools"
version = "0.1.0"
edition = "2021"          # language edition, NOT compiler version

[dependencies]

Editions are worth understanding. An edition is an opt-in set of breaking language changes (2015, 2018, 2021, 2024). A 2015-edition crate and a 2021-edition crate can be linked into the same binary — the compiler supports all editions simultaneously. This is how Rust evolves the language without an ecosystem split. Contrast with the Python 2→3 migration, which took over a decade precisely because there was no such mechanism.

// src/main.rs
fn main() {
    println!("Hello, sim!");
}
cargo run          # compile (debug) and execute
cargo build        # → target/debug/sim_tools
cargo check        # type + borrow check ONLY, no codegen — 3-10x faster

cargo check is the command you will use most. During development you want the borrow checker's verdict, not a binary. Wire it to your editor's save hook.


Debug vs Release: A 10-100x Difference

This trips up everyone benchmarking Rust for the first time.

cargo build              # debug:   -C opt-level=0, debug_assertions on,
                         #          integer overflow PANICS
cargo build --release    # release: -C opt-level=3, overflow WRAPS silently
fn main() {
    let x: u8 = 255;
    let y = x + 1;
    println!("{y}");
}
// debug:   thread 'main' panicked at 'attempt to add with overflow'
// release: prints 0

That difference is deliberate: overflow checks catch real bugs during development, and are removed in release because the check costs a branch in every arithmetic op. If you need defined behavior in both, say so explicitly:

let y = x.wrapping_add(1);       // 0, always
let y = x.saturating_add(1);     // 255, always
let y = x.checked_add(1);        // None, always  → Option<u8>
let (y, overflowed) = x.overflowing_add(1);  // (0, true)

This is the same discipline as being explicit about width and truncation semantics in SystemVerilog integer types rather than relying on implicit context- dependent sizing rules. Never benchmark a debug build.

Custom profiles:

[profile.release]
lto = "fat"           # link-time optimization across crates
codegen-units = 1     # slower build, better optimization
panic = "abort"       # no unwinding tables → smaller binary
strip = "symbols"

[profile.dev]
opt-level = 1         # make debug builds tolerable for compute-heavy work

[profile.dev.package."*"]
opt-level = 3         # optimize DEPENDENCIES even in debug — big win

Dependencies and Semantic Versioning

cargo add serde --features derive
cargo add tokio --features full
cargo add anyhow thiserror
[dependencies]
serde = { version = "1.0", features = ["derive"] }
regex = "1.11"
rand  = "0.8"

[dev-dependencies]          # test/bench/example only, not in the shipped binary
criterion = "0.5"
proptest  = "1"

[build-dependencies]        # for build.rs
cc = "1.0"

The version string is a caret requirement by default: "1.11" means >=1.11.0, <2.0.0. Cargo resolves the highest compatible version and records the exact choice in Cargo.lock.

WrittenMeans
"1.11" or "^1.11">=1.11.0, <2.0.0 — the default
"~1.11">=1.11.0, <1.12.0
"=1.11.3"exactly that version
"*"any — never do this

Commit Cargo.lock for binaries; historically it was omitted for libraries (so downstream consumers resolve their own), though committing it for libraries is now also accepted practice since it only affects your own CI.

A genuinely important Cargo capability: two semver-incompatible versions of the same crate can coexist in one dependency graph. If crate A needs rand 0.7 and crate B needs rand 0.8, Cargo links both. This eliminates the diamond-dependency deadlock that plagues C++ and Python. The cost is binary size and the confusing error expected rand::Rng, found rand::Rng when two versions leak into one interface.


Workspaces: Multi-Crate Projects

Once a project exceeds one crate, use a workspace. All members share one target/ directory, one lockfile, and one dependency resolution.

# ./Cargo.toml  (workspace root, no [package])
[workspace]
resolver = "2"
members = ["vcd-parser", "cov-merge", "cli"]

[workspace.dependencies]     # centralize versions once
serde = { version = "1.0", features = ["derive"] }
anyhow = "1"
# ./cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2021"

[dependencies]
vcd-parser = { path = "../vcd-parser" }
serde  = { workspace = true }
anyhow = { workspace = true }
cargo build --workspace       # everything
cargo test -p vcd-parser      # one member
cargo run -p cli -- --help    # note the `--` separating cargo args from program args

This is directly analogous to organizing a testbench into separate compiled UVM packages rather than one monolithic compile — shared dependency versions, independent unit tests, explicit inter-package boundaries.


The Commands That Matter Day to Day

cargo check                     # fastest feedback: types + borrows, no codegen
cargo clippy --all-targets -- -D warnings   # lints; treat warnings as errors in CI
cargo fmt                       # canonical formatting, zero config, zero debate
cargo test                      # unit + integration + doc tests  → Rust Testing
cargo doc --open                # build and view docs for YOUR crate + all deps
cargo tree                      # dependency graph
cargo tree -d                   # find duplicate versions of one crate
cargo build --timings           # HTML report of what is slow to compile
cargo bench                     # benchmarks
cargo add / cargo remove        # edit Cargo.toml correctly

Worth installing separately:

cargo install cargo-watch       # cargo watch -x check -x test
cargo install cargo-nextest     # much faster, better-isolated test runner
cargo install cargo-audit       # RUSTSEC advisory scan — wire into CI
cargo install cargo-expand      # see what macros expanded to → Rust Macros
cargo install cargo-flamegraph  # profiling

cargo doc --open deserves emphasis. It builds documentation for your crate and every dependency at the exact version you resolved. No more reading docs for a version you are not using. Doc comments (///) support Markdown and their code blocks are compiled and run as tests, so documentation examples cannot silently rot.


Examples

Example 1: A tool with a dependency, end to end

cargo new logscan && cd logscan
cargo add regex
cargo add clap --features derive
// src/main.rs
use clap::Parser;
use regex::Regex;
use std::fs;

#[derive(Parser)]
#[command(about = "Count UVM_ERROR lines matching a pattern")]
struct Args {
    /// Log file to scan
    path: String,
    /// Regex to match
    #[arg(short, long, default_value = r"UVM_ERROR")]
    pattern: String,
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let args = Args::parse();
    let re = Regex::new(&args.pattern)?;
    let text = fs::read_to_string(&args.path)?;

    let hits: Vec<_> = text.lines().filter(|l| re.is_match(l)).collect();
    println!("{} match(es)", hits.len());
    for h in hits.iter().take(10) {
        println!("  {h}");
    }
    Ok(())
}
cargo run -- sim.log --pattern 'UVM_(ERROR|FATAL)'
cargo build --release && ./target/release/logscan sim.log

Note fn main() -> Result<...>: main may return a Result, and ? works inside it. On Err, the process prints the error's Debug form and exits non-zero. See Rust Error Handling.

Example 2: Feature flags — conditional compilation done properly

[features]
default = ["fsdb"]
fsdb = []
vcd  = []
trace = ["dep:tracing"]

[dependencies]
tracing = { version = "0.1", optional = true }
#[cfg(feature = "fsdb")]
pub mod fsdb_reader;

#[cfg(feature = "vcd")]
pub mod vcd_reader;

pub fn open(path: &str) -> Result<Box<dyn WaveReader>, Error> {
    #[cfg(feature = "fsdb")]
    if path.ends_with(".fsdb") { return fsdb_reader::open(path); }

    #[cfg(feature = "vcd")]
    if path.ends_with(".vcd") { return vcd_reader::open(path); }

    Err(Error::UnknownFormat)
}
cargo build --no-default-features --features vcd

This is ` ifdef `` (SystemVerilog compiler directives) with two critical improvements: features are declared in the manifest rather than being arbitrary strings, and they are additive — enabling a feature must never break a crate that did not enable it, because Cargo unifies features across the whole dependency graph. A feature that removes an API is a bug.

Example 3: Cross-compiling for an embedded target

rustup target add thumbv7em-none-eabihf
cargo build --release --target thumbv7em-none-eabihf
# .cargo/config.toml
[build]
target = "thumbv7em-none-eabihf"

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F407VGTx"
rustflags = ["-C", "link-arg=-Tlink.x"]
#![no_std]      // no OS, no allocator, no std::vec
#![no_main]     // no C runtime startup

use cortex_m_rt::entry;
use panic_halt as _;

#[entry]
fn main() -> ! {
    loop { }
}

cargo run now flashes and runs on the target via probe-rs. Cross-compilation in Rust is a one-line rustup target add because the standard library is distributed pre-built for every supported target. Anyone who has cross-compiled a C toolchain by hand will recognize how much work that sentence is hiding.


Interoperating With an Existing Build System

Real EDA and firmware flows are Make- or Bazel-driven. Cargo has to be a citizen, not the king.

# Makefile — drive cargo from an existing flow
RUST_LIB := target/release/libdpi_shim.a

$(RUST_LIB): $(wildcard src/*.rs) Cargo.toml
	cargo build --release

simv: $(RUST_LIB) tb.sv
	vcs -sverilog tb.sv $(RUST_LIB) -o simv
[lib]
crate-type = ["staticlib"]    # .a for linking into a simulator
# or ["cdylib"]               # .so / .dll
#[no_mangle]
pub extern "C" fn crc32_check(data: *const u8, len: usize) -> u32 {
    // SAFETY: caller guarantees `data` is valid for `len` bytes.
    let slice = unsafe { std::slice::from_raw_parts(data, len) };
    slice.iter().fold(0u32, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as u32))
}

#[no_mangle] plus extern "C" gives a symbol a C-compatible name and ABI, which is exactly what a DPI import needs. The unsafe block is confined to the FFI boundary; everything above it is checked normally. See Rust Unsafe Rust for how to write that boundary correctly.


What Trips People Up

  • Benchmarking a debug build. 10-100x slower. Always --release.
  • cargo run -- --flag vs cargo run --flag. Without the bare --, Cargo eats the flag.
  • target/ is enormous. Tens of GB across a workspace's profiles is normal. It is fully
  • reproducible; delete it freely (cargo clean). Never commit it.

  • Expecting cargo build to rebuild a dependency you edited by hand in ~/.cargo.
  • Use [patch] or a path = dependency instead; the registry cache is treated as immutable.

  • Feature unification surprises. If any crate in the graph enables a feature, it is
  • enabled for everyone. A dependency turning on serde/std can break your no_std build. cargo tree -f '{p} {f}' shows resolved features.

  • Nightly-only features leaking in. If a crate needs #![feature(...)] it will not build
  • on stable. Check before adopting.

  • Forgetting rust-toolchain.toml. CI drifting to a newer compiler that added a lint is
  • a recurring, avoidable annoyance.


Related

  • Rust What and Why — why the language exists at all
  • Rust Modules and Crates — crates, modules, visibility, and the compilation unit
  • Rust Testing — cargo test, unit vs integration vs doc tests
  • Rust Unsafe Rust — writing the FFI boundary correctly
  • Rust Performance and Zero Cost Abstractions — profiles, LTO, and verifying codegen
  • Go Setup and Toolchain — the same problem solved with a very different philosophy
  • Python Packaging and Distribution — for contrast on dependency resolution
  • Rust Index — full topic map

Practice lab

Build the smallest runnable Rust program that demonstrates Rust Setup and Cargo. Add a focused test or Clippy check, trigger one relevant compiler or runtime failure deliberately, and explain the evidence used to correct it.

Review questions

  1. What problem does Rust Setup and Cargo solve, and what assumptions does it rely on?
  2. Which boundary or failure case is easiest to miss, and how would you expose it?
  3. What alternative design would you consider, and what trade-off would change the decision?
  4. What artifact, trace, test, or metric proves that your implementation is correct?

Completion evidence

  • A working artifact, annotated trace, or reproducible experiment
  • At least one normal case and one deliberately failing or boundary case
  • A concise explanation of the design choice and its trade-offs
  • Saved output showing how correctness was evaluated