Skip to content
C Codeloom
Rust

Cargo Basics: Building, Testing, and Publishing Rust Code

A practical tour of Cargo — creating projects, managing dependencies and features, running tests, building release binaries, and using workspaces.

·6 min read · By Yash Kesharwani
Intermediate 9 min read

What you'll learn

  • Scaffold projects with cargo new and init
  • Add dependencies and toggle Cargo features
  • Run, test, and benchmark with subcommands
  • Build optimized binaries with --release
  • Organize multi-crate repos as workspaces

Prerequisites

  • A working Rust toolchain — see /blog/rust-install-and-first-program
  • Comfort with types — see /blog/rust-variables-and-types
  • Functions and modules basics — see /blog/rust-functions

Cargo is Rust’s package manager, build tool, test runner, and publishing pipeline rolled into one. Once you internalize a handful of subcommands and the structure of Cargo.toml, almost every Rust project feels familiar — the same shape, the same commands, the same conventions.

Creating a Project

cargo new scaffolds a fresh binary crate.

cargo new hello
cd hello

You get a Cargo.toml, a src/main.rs, and a Git repository. For a library crate, pass --lib.

cargo new mylib --lib

If you already have a directory you want to turn into a crate, use cargo init inside it.

cargo init --lib

The generated Cargo.toml looks like:

[package]
name = "hello"
version = "0.1.0"
edition = "2021"

[dependencies]

The edition field selects the language edition; new crates should usually start on the latest.

Adding Dependencies

Dependencies live under [dependencies]. The easiest way to add one is cargo add.

cargo add serde --features derive
cargo add reqwest

Cargo edits Cargo.toml for you, picking a sensible version constraint.

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

Version requirements default to “compatible with this version” semantics. serde = "1" accepts any 1.x release. Pin more tightly when needed, or use =1.2.3 for an exact match.

Dev-only dependencies (used by tests, examples, and benchmarks) go under [dev-dependencies].

[dev-dependencies]
pretty_assertions = "1"

Build-time dependencies for build.rs go under [build-dependencies].

Features

Features are named knobs that enable optional behavior or dependencies. Define them under [features].

[features]
default = ["json"]
json = ["dep:serde_json"]
verbose-logs = []

default lists features that are on unless the user opts out. Consumers can disable with default-features = false and re-enable selectively.

[dependencies]
mycrate = { version = "0.3", default-features = false, features = ["json"] }

Inside your code, gate items on a feature with cfg.

#[cfg(feature = "verbose-logs")]
fn log(msg: &str) { eprintln!("[log] {}", msg); }

#[cfg(not(feature = "verbose-logs"))]
fn log(_msg: &str) {}

Use features sparingly. Every combination is a configuration the test matrix has to consider.

The Day-to-Day Subcommands

A handful of commands handle almost every workflow.

cargo run           # compile (debug) and run the binary
cargo run -- --flag # pass args after `--`
cargo build         # compile only
cargo check         # type-check without producing a binary (fast)
cargo test          # run unit, integration, and doc tests
cargo doc --open    # build and open API docs
cargo fmt           # format code with rustfmt
cargo clippy        # lint with Clippy

cargo check is the fastest feedback loop while you are iterating on a refactor; it skips codegen entirely. Save cargo build for when you actually need the binary.

Tests

Cargo discovers tests in three places:

  1. #[cfg(test)] modules inside source files for unit tests.
  2. Files under tests/ for integration tests (each file is its own crate).
  3. /// doc comments with code fences for doctests in library crates.

A small unit test:

pub fn add(a: i32, b: i32) -> i32 { a + b }

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn adds_positive() {
        assert_eq!(add(2, 3), 5);
    }
}

Run them all with cargo test. Filter by name to focus.

cargo test adds_positive
cargo test --test integration_login   # one integration file

Debug vs Release Builds

By default Cargo builds in debug mode: fast compiles, slow runtime, full assertions and overflow checks. For benchmarks or production binaries, pass --release.

cargo build --release
cargo run --release
./target/release/hello

Release builds enable optimizations and can be dramatically faster. Tune further under [profile.release].

[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"

lto = "thin" enables link-time optimization with a smaller compile-time cost than "fat". codegen-units = 1 trades parallel codegen for slightly better optimization.

Workspaces

For a repository that contains several crates that depend on each other, create a workspace. At the repo root, a workspace Cargo.toml:

[workspace]
members = ["app", "core", "cli"]
resolver = "2"

Each member is a normal crate with its own Cargo.toml. They share one target/ directory and one Cargo.lock, so cross-crate builds are fast and version-consistent.

A member depends on another by path:

[dependencies]
core = { path = "../core" }

Workspace-wide commands operate on every member.

cargo build --workspace
cargo test --workspace
cargo check -p core         # just one crate

Workspaces also let you share dependency versions through [workspace.dependencies], so every crate picks up the same serde version without restating it.

Publishing to crates.io

For libraries you intend to share publicly, fill in metadata in Cargo.toml.

[package]
name = "mylib"
version = "0.1.0"
edition = "2021"
authors = ["Your Name <you@example.com>"]
description = "A short, useful sentence."
license = "MIT OR Apache-2.0"
repository = "https://github.com/you/mylib"
readme = "README.md"
keywords = ["parser", "config"]
categories = ["parsing"]

Authenticate and publish:

cargo login            # paste API token from crates.io
cargo publish --dry-run
cargo publish

The dry run catches missing metadata, uncommitted changes, and other common mistakes. Once published, a version is permanent — you can yank it from new resolutions but cannot delete it.

Useful Quality of Life

A few flags worth keeping in muscle memory.

cargo update          # bump Cargo.lock to newer compatible versions
cargo tree            # show the dependency graph
cargo expand          # show macro-expanded code (requires cargo-expand)
cargo build --locked  # fail if Cargo.lock would change (good for CI)

In CI, prefer cargo test --locked --workspace to ensure reproducible builds.

Wrap up

Cargo is the connective tissue of the Rust ecosystem: cargo new to start, cargo add for dependencies, cargo run and cargo test while iterating, --release when speed matters, and workspaces when one crate is not enough. Internalize this small vocabulary and almost every Rust project becomes a comfortable place to work.