Skip to content
beginner Phase 1 · Toolchain, Cargo, and Language Syntax

Cargo Projects and Commands

Scaffold binaries and libraries and run the build, test, and run workflow.

35m
0 problems
Topic Progress 0%

cargo new Builds the Skeleton; Cargo.toml Declares the Contract

Every Rust project is a crate, and cargo is the only sane way to make one. Run cargo new hello and you get a directory containing exactly what a minimal crate needs:

hello/
├── Cargo.toml      manifest: package metadata + dependencies
├── .gitignore      ignores /target
└── src/
    └── main.rs     fn main() { println!("Hello, world!"); }

cargo new --lib hello swaps main.rs for lib.rs and generates a src/lib.rs with a unit-test module. That binary-versus-library choice is the crate's kind: a binary crate produces an executable with an entry point, a library crate produces a .rlib that other crates can use. Real projects almost always start as a library with a thin binary attached, because code you want to test must live in the library target.

The manifest is small but every field does work:

[package]
name = "orders"          # crate name; lib crates conventionally use underscores
version = "0.1.0"        # your crate's version, semver-interpreted
edition = "2021"         # language/idiom version: 2015, 2018, 2021, 2024
rust-version = "1.78"    # minimum supported compiler (MSRV)

[dependencies]
serde = { version = "1", features = ["derive"] }

The edition field deserves attention because it changes how your code compiles without changing the compiler: edition 2018 introduced the extern crate-less module system and uniform paths, 2021 added disjoint crate captures for closures and IntoIterator for arrays, 2024 tightens lifetime-capture and unsafe rules. Editions never break old code silently — the compiler migrates you with cargo fix --edition.

A crate with multiple executables puts each in its own file under src/bin/:

src/
├── lib.rs        the library target: pub fn parse(...)
├── main.rs       binary "orders": calls orders::parse
└── bin/
    └── importer.rs   binary "importer": a second executable in one crate

This layout is the standard interview answer to "how do you ship a CLI and a server from one package?" — one package, one library, N binaries. Modules inside a target are declared with mod, and files like src/parse.rs join the tree as mod parse;; the module system is genuinely covered in a later topic, so the rule to hold now is: lib.rs/main.rs are crate roots, everything else hangs off a mod declaration.

Finally, cargo new initializes a git repository and a .gitignore with /target in one step. Committing target/ is a classic beginner mistake — it holds hundreds of megabytes of regenerable build artifacts.

Two related commands trip people up: cargo init creates a manifest in a directory that already exists, while cargo new refuses if the target directory is non-empty and scaffolds it fresh. Both default to a git repo; --vcs none disables that for directories already under version control.

Names follow rules you only meet when they are enforced. A package name may contain letters, digits, -, and _, must start with a letter, and hyphens become underscores when you import the crate: a package named orders-api is written use orders_api::parse; because cargo passes --crate-name orders_api to rustc. That is why library packages conventionally use underscores — the name you publish and the name you use then agree.

Sizing the manifest up a notch, three fields change what cargo builds:

[package]
name = "orders-api"
default-run = "orders"      # which binary `cargo run` picks
autobins = false            # stop auto-discovering src/bin/*

[lib]
name = "orders_core"        # rename the library target only
path = "src/core/lib.rs"    # move its root off the default

default-run is the practical one when a package ships src/main.rs plus src/bin/importer.rs: plain cargo run would otherwise fail and demand --bin. Renaming the lib target changes only the artifact name — the package name in Cargo.toml still identifies the dependency to everyone else.

The interview framing for this whole section is "how do you lay out a crate so it stays testable?" The answer in one line: put the logic in the library target and keep binaries as thin dispatchers, because tests/ integration crates and doc tests can only reach library code, never fn main.

check, build, run, release: What Each Command Actually Writes

Cargo's build commands differ in how much work they do, and knowing which to reach for is the difference between a five-second and a five-minute feedback loop.

Command Does Writes
cargo check type-check + borrow-check, no codegen metadata only — fastest
cargo build compile debug binary target/debug/<name>
cargo run build then execute the binary same as build
cargo build --release optimize target/release/<name>
cargo clean delete everything removes target/

cargo check is the command you run constantly while editing: it skips LLVM code generation entirely, which is roughly 60–70% of compile time, and still produces every error you care about — type errors, borrow-checker errors, trait resolution. The trade is that it cannot produce a runnable binary, so cargo run does the full debug build before executing.

Debug versus release is not a minor flag — it changes program behavior:

[profile.dev]
opt-level = 0        # fast compiles, slow runs; assertions ON
overflow-checks = true

[profile.release]
opt-level = 3        # slow compiles, fast runs; assertions OFF
lto = "thin"         # link-time optimization across crates
codegen-units = 16
strip = true

Integer overflow panics in debug builds and wraps in release builds (you will meet this precisely in Primitive Types and Overflow), and debug_assert! runs only in dev. A benchmark run against a debug binary measures nothing real; a bug that "only happens in production" is often a debug assertion that release compiled away. cargo run --release exists for exactly this reason.

Incremental compilation is on by default in dev: cargo fingerprints every crate and recompiles only what changed, keeping per-crate artifacts in target/debug/incremental/. Two habits defeat it needlessly — cargo clean on every hiccup (cargo rebuilds fine without it) and editing files cargo cannot see (generated sources must be declared cargo:rerun-if-changed in build.rs). Build scripts are a crate's build.rs that runs before compilation and can emit linker flags, generate code, and probe the system — you will meet them when C interop arrives.

For a workspace of many crates, every command takes -p <crate> to target one package and --workspace to run across all. cargo run --bin importer disambiguates which executable when a crate ships several. These flags, plus --release and --target, cover most real invocations; anything else is one cargo help away.

Two more dials on the build loop are worth having. cargo build --timings writes an HTML report showing exactly which crates dominate your compile time — the fastest way to discover that one dependency is 40% of your wall clock. And when a heavy dependency makes debug runs crawl, you can optimize it without slowing your own edit-compile cycle:

[profile.dev.package.regex]
opt-level = 3      # slow dependency builds fast, your crate still compiles at 0

The same mechanism in reverse explains a common production surprise: code that takes 5 ms in debug takes 0.5 ms in release, so any timing claim about your program is meaningless until you name the profile it ran under.

Passing arguments and isolating builds are daily needs:

cargo run --bin importer -- --input data.csv   # everything after -- goes to your program
CARGO_TARGET_DIR=/scratch/target cargo build   # move the artifact tree (shared CI caches)
cargo build --offline                          # fail instead of touching the network

That last one matters on CI and planes: a clean checkout with a populated cache builds fully offline, proving the lock file and vendored sources are sufficient — reproducibility you can test with a flag. Finally, cargo fix applies the compiler's own suggested edits (including edition migrations) so mechanical repairs land as a diff you review rather than keystrokes you retype.

cargo test Runs Three Kinds of Tests in Separate Processes

cargo test is not one test runner — it compiles your crate plus every test target and executes them through a generated harness. Three kinds of tests exist, and each lives in a different place.

Unit tests sit inside the module they test, gated by #[cfg(test)] so they compile only under the test harness:

// src/lib.rs
pub fn add(left: u64, right: u64) -> u64 { left + right }

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn adds_two_numbers() {
        assert_eq!(add(2, 2), 4);
    }
}

Integration tests live as separate crates under tests/, compiled against your library's public API — they cannot reach private items, which is precisely the point:

tests/
└── api.rs    use orders::parse;  // only the pub surface

Doc tests are the third kind and unique to Rust: every fenced rust block in a /// doc comment is compiled and run by cargo test:

/// Adds two numbers.
///
/// ```
/// let sum = orders::add(2, 2);
/// assert_eq!(sum, 4);
/// ```
pub fn add(left: u64, right: u64) -> u64 { left + right }

That makes documentation executable — an example that stops compiling fails CI. Each test runs as a thread inside a harness binary, and one #[test] panicking marks only that test failed; the harness catches the panic and keeps going. Two practical controls: cargo test add_two filters by substring name, and cargo test -- --test-threads=1 serializes execution for tests that share a resource. Mark resource-heavy tests #[ignore] and opt them in with cargo test -- --ignored.

Dependencies round out the workflow. cargo add serde --features derive edits Cargo.toml for you, then cargo build downloads crates into ~/.cargo/registry and compiles them into target/debug/deps. The version requirement "1.2" means >=1.2.0, <2.0.0 — caret semantics — so compatible patch and minor updates flow in on the next cargo update. Cargo.lock is committed for applications: it pins the exact resolved versions so every machine and CI builds the same graph. Libraries publish their Cargo.toml requirements; consumers' lock files decide what those resolve to.

Assertions are not the only contract a test can state. A test may return Result so ? works directly, and failure of the returned Err fails the test:

#[test]
fn parses_order() -> Result<(), ParseError> {
    let order = orders::parse("A-100,3")?;
    assert_eq!(order.qty, 3);
    Ok(())
}

#[test]
#[should_panic(expected = "index out of bounds")]
fn rejects_empty_input() {
    orders::parse("");
}

Doc tests get escape hatches for examples that are illustrative but not runnable: ```rust,ignore compiles nothing, ```rust,no_run compiles without executing — the right choice for a snippet that opens a network port — and ```text opts out entirely.

Dependency inspection has one command worth memorizing: cargo tree prints the resolved graph with duplicates collapsed, -e features shows which feature flags pulled a crate in, and -i serde inverts the tree to answer "who depends on this?" When two versions of one crate appear, cargo tree -d names them and tells you which requirement blocked unification. Repairing the graph is equally direct: cargo update -p serde --precise 1.0.199 re-resolves a single crate to an exact version while leaving the rest of the lock file untouched — the rollback you want when a point release breaks the build.

Test-only dependencies belong in [dev-dependencies]: criterion for benchmarks and tempfile for scratch directories land there, are compiled for cargo test and cargo bench, and are never compiled into the artifact you ship.

Quiz

1. You want the fastest possible feedback while typing Rust code — errors but no executable. Which command?

Question 1 options

2. A program panics with 'attempt to multiply with overflow' on your laptop but runs fine in the released binary. What is the most likely explanation?

Question 2 options

3. Where must a test live so that it can access a crate's private functions?

Question 3 options

4. A dependency requirement is serde = "1.2". Which versions can cargo resolve it to?

Question 4 options

Flashcards

Question

What is the difference between a binary crate and a library crate, and how does cargo create each?

Answer

A binary crate has an executable entry point (src/main.rs) and produces a runnable artifact; a library crate (src/lib.rs) exposes an API for other crates to depend on. `cargo new --bin` scaffolds the former, `cargo new --lib` the latter. Multiple binaries in one package go in src/bin/<name>.rs.

Question

What does cargo check do that cargo build does not, and why is it faster?

Answer

cargo check performs name resolution, type checking, and borrow checking but skips LLVM code generation and linking entirely — no binary is produced. Since codegen is the majority of compile time, check gives error feedback in a fraction of the build time; you run build/run only when you need an executable.

Question

Name the three kinds of tests cargo test runs and where each lives.

Answer

Unit tests: inside `#[cfg(test)] mod tests` in the same module — can access private items. Integration tests: separate crates under tests/ compiled against the public API only. Doc tests: fenced rust blocks in /// documentation, compiled and executed as tests. All run via generated harness binaries, one process per target.

Question

How do behavior and speed differ between the dev and release profiles, and what must you remember when benchmarking?

Answer

dev: opt-level 0, assertions and overflow checks on — fast to compile, slow to run. release: opt-level 3, assertions off, optional LTO — slow to compile, fast to run. Overflow panics in debug but wraps in release. Benchmarks must use `cargo run --release` / `cargo bench`, otherwise you measure the unoptimized build.

Question

What does a version requirement like "1.2" mean, and what role does Cargo.lock play?

Answer

Caret semantics: >=1.2.0 and below the next semver-major bump (<2.0.0), so compatible minors and patches can resolve. Cargo.lock pins the exact versions actually resolved. Commit the lock file for applications (reproducible deploys); libraries publish requirements instead and let consumers' lock files resolve them.

Revision Notes

Key Takeaways

  • 1. A package is one or more crates: lib.rs is the library target, main.rs and src/bin/* are binary targets; ship testable code in the library
  • 2. cargo check skips codegen and is the everyday feedback command; cargo run --release is required for honest benchmarks because profiles change behavior (overflow, assertions)
  • 3. cargo test executes three test kinds — in-module unit tests, tests/ integration crates, and executable doc examples — each in its own compiled target
  • 4. Edition is a per-package compile-mode switch (2015/2018/2021/2024); cargo fix --edition migrates code without manual rewrites
  • 5. Version requirements are ranges, Cargo.lock is the exact resolution; commit the lock file for applications so every machine builds the same graph

Interview Tips

  • • "What is the difference between cargo check and cargo build?" — check stops after type/borrow analysis, build runs LLVM codegen and linking; quote the rough 60–70% time saving and note check produces no binary
  • • "How do you structure a CLI plus a server in one repo?" — one package, one library target holding the logic, two binary targets (src/main.rs and src/bin/server.rs); tests exercise the library
  • • "Why does my program behave differently in production?" — profiles: release disables overflow checks and debug_assert!, changes optimization; the answer shows you know debug-vs-release is semantic, not just performance
  • • "When do you commit Cargo.lock?" — always for applications/binaries (reproducible deployments), effectively never enforced for libraries; explain that consumers' lock files resolve the library's declared ranges

Cheat Sheet

Cargo Projects and Commands

Scaffold: cargo new orders (bin) · cargo new --lib orders (lib) · multiple binaries → src/bin/<name>.rs

Manifest essentials:

[package]
name = "orders"
version = "0.1.0"
edition = "2021"
rust-version = "1.78"

[dependencies]
serde = { version = "1", features = ["derive"] }

Feedback loop:

Command Use
cargo check fastest errors — no codegen
cargo build debug binary in target/debug
cargo run [-- args] build + execute
cargo run --release optimized (assertions off, overflow wraps!)
cargo test [filter] unit + integration + doc tests
cargo clippy / cargo fmt lint / format

Tests: unit = #[cfg(test)] mod in-module (sees privates) · integration = tests/*.rs (public API only) · doc tests = fenced rust blocks in /// docs. Filter: cargo test substring · serialize: cargo test -- --test-threads=1 · heavy: #[ignore].

Deps: cargo add serde --features derive · "1.2" = >=1.2.0 <2.0.0 · commit Cargo.lock for apps · cargo update re-resolves within requirements.

Continue Learning