Cargo and the Rust Ecosystem
Cargo is Rust's build system and package manager. It handles building code, downloading dependencies, running tests, generating documentation, and publishing libraries. Mastering Cargo is essential to being productive in Rust.
Cargo Basics
Creating a Project
# Binary project
cargo new myapp
# Creates:
# myapp/
# ├── Cargo.toml
# └── src/
# └── main.rs
# Library project
cargo new mylib --lib
# Creates:
# mylib/
# ├── Cargo.toml
# └── src/
# └── lib.rs
Cargo.toml
The manifest file that describes your project:
[package]
name = "myapp"
version = "0.1.0"
edition = "2024" # Rust 1.93+ defaults to 2024; 2021 is also still supported
authors = ["Your Name <you@example.com>"]
description = "A short description of what this does"
license = "MIT"
repository = "https://github.com/you/myapp"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
clap = "4"
[dev-dependencies]
tempfile = "3"
criterion = "0.5"
[build-dependencies]
cc = "1.0"
Essential Commands
| Command | Purpose |
|---|---|
cargo build | Compile the project (debug mode) |
cargo build --release | Compile with optimizations |
cargo run | Build and run the binary |
cargo check | Check for errors without building |
cargo test | Run all tests |
cargo fmt | Format code with rustfmt |
cargo clippy | Run the linter |
cargo doc --open | Generate and open documentation |
cargo clean | Remove build artifacts |
cargo update | Update dependencies |
Build Profiles
# Cargo.toml
# Debug profile (cargo build)
[profile.dev]
opt-level = 0 # No optimization
debug = true # Full debug info
# Release profile (cargo build --release)
[profile.release]
opt-level = 3 # Maximum optimization
lto = true # Link-time optimization
strip = true # Strip debug symbols
panic = "abort" # Abort on panic (smaller binary)
# Test profile
[profile.test]
opt-level = 1 # Some optimization for faster tests
# Custom profile
[profile.profiling]
inherits = "release"
debug = true # Debug symbols for profiling tools
Dependencies and Crates.io
Adding Dependencies
[dependencies]
# From crates.io (most common)
serde = "1.0" # Compatible with 1.0.x
serde = "=1.0.193" # Exact version
serde = ">=1.0, <2.0" # Version range
# With features
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# From git
my_crate = { git = "https://github.com/user/repo" }
my_crate = { git = "https://github.com/user/repo", branch = "develop" }
# From local path
my_crate = { path = "../my_crate" }
# Optional dependency (enabled via features)
fancy-output = { version = "1.0", optional = true }
Version Requirements
| Specifier | Meaning |
|---|---|
"1.0" | >=1.0.0, <2.0.0 (caret, default) |
"~1.0" | >=1.0.0, <1.1.0 (tilde) |
"=1.0.5" | Exactly 1.0.5 |
">=1.0" | 1.0 or higher |
"*" | Any version |
Cargo.lock
- Binary projects: commit
Cargo.lockto version control for reproducible builds - Library projects: don't commit
Cargo.lock(let consumers resolve versions)
Feature Flags
Define optional functionality in your crate:
# Cargo.toml
[features]
default = ["json"] # Features enabled by default
json = ["dep:serde_json"] # Enables the serde_json dependency
async = ["dep:tokio"] # Enables async support
full = ["json", "async"] # Combines features
Using features in code:
// Only compiled when the "json" feature is enabled
#[cfg(feature = "json")]
pub mod json {
use serde_json;
pub fn parse(input: &str) -> serde_json::Value {
serde_json::from_str(input).unwrap()
}
}
#[cfg(feature = "async")]
pub async fn fetch_data(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
fn main() {
#[cfg(feature = "json")]
{
let data = json::parse(r#"{"key": "value"}"#);
println!("{}", data);
}
#[cfg(not(feature = "json"))]
{
println!("JSON support not enabled");
}
}
Testing with Cargo
Unit Tests
Unit tests live in the same file as the code they test:
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
pub fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, 1), 0);
}
#[test]
fn test_divide() {
assert_eq!(divide(10.0, 2.0), Ok(5.0));
}
#[test]
fn test_divide_by_zero() {
assert!(divide(10.0, 0.0).is_err());
}
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_panic() {
let v = vec![1, 2, 3];
let _ = v[99];
}
#[test]
fn test_result() -> Result<(), String> {
let result = divide(10.0, 2.0)?;
assert!((result - 5.0).abs() < f64::EPSILON);
Ok(())
}
#[test]
#[ignore] // Skip unless explicitly requested
fn expensive_test() {
// Long-running test
std::thread::sleep(std::time::Duration::from_secs(10));
}
}
Test Assertions
| Macro | Purpose |
|---|---|
assert!(expr) | Assert expression is true |
assert_eq!(a, b) | Assert equality |
assert_ne!(a, b) | Assert inequality |
assert!(expr, "msg") | Assert with custom message |
#[should_panic] | Expect test to panic |
Running Tests
# Run all tests
cargo test
# Run tests matching a name
cargo test test_add
# Run tests in a specific module
cargo test tests::
# Run ignored tests
cargo test -- --ignored
# Run all tests including ignored
cargo test -- --include-ignored
# Show stdout even for passing tests
cargo test -- --nocapture
# Run tests with a single thread
cargo test -- --test-threads=1
# Run only doc tests
cargo test --doc
# Run tests in release mode
cargo test --release
Integration Tests
Integration tests live in the tests/ directory and test your public API:
mylib/
├── Cargo.toml
├── src/
│ └── lib.rs
└── tests/
├── common/
│ └── mod.rs # Shared test helpers
├── integration_test.rs
└── api_test.rs
// src/lib.rs
pub struct Calculator;
impl Calculator {
pub fn new() -> Self {
Calculator
}
pub fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
pub fn multiply(&self, a: i32, b: i32) -> i32 {
a * b
}
}
// tests/common/mod.rs
use mylib::Calculator;
pub fn setup() -> Calculator {
Calculator::new()
}
// tests/integration_test.rs
use mylib::Calculator;
mod common;
#[test]
fn test_calculator_operations() {
let calc = common::setup();
assert_eq!(calc.add(2, 3), 5);
assert_eq!(calc.multiply(4, 5), 20);
}
#[test]
fn test_negative_numbers() {
let calc = Calculator::new();
assert_eq!(calc.add(-5, 3), -2);
assert_eq!(calc.multiply(-2, 6), -12);
}
Doc Tests
Code examples in documentation are automatically tested:
/// Adds two numbers together.
///
/// # Examples
///
/// ```
/// use mylib::add;
///
/// assert_eq!(add(2, 3), 5);
/// assert_eq!(add(-1, 1), 0);
/// ```
///
/// # Panics
///
/// This function does not panic.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Safely divides two numbers.
///
/// Returns an error if the divisor is zero.
///
/// ```
/// use mylib::divide;
///
/// let result = divide(10.0, 3.0).unwrap();
/// assert!((result - 3.333).abs() < 0.01);
/// ```
///
/// ```
/// use mylib::divide;
///
/// assert!(divide(1.0, 0.0).is_err());
/// ```
pub fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err("Division by zero".to_string())
} else {
Ok(a / b)
}
}
Benchmarking
Using Criterion
Criterion is the standard benchmarking library for Rust:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn fibonacci(n: u64) -> u64 {
match n {
0 => 0,
1 => 1,
n => fibonacci(n - 1) + fibonacci(n - 2),
}
}
fn fibonacci_iterative(n: u64) -> u64 {
let mut a = 0u64;
let mut b = 1u64;
for _ in 0..n {
let temp = b;
b = a + b;
a = temp;
}
a
}
fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("fib 20 recursive", |b| {
b.iter(|| fibonacci(black_box(20)))
});
c.bench_function("fib 20 iterative", |b| {
b.iter(|| fibonacci_iterative(black_box(20)))
});
// Compare implementations in a group
let mut group = c.benchmark_group("fibonacci");
for size in [10, 15, 20].iter() {
group.bench_with_input(
format!("recursive/{}", size),
size,
|b, &s| b.iter(|| fibonacci(black_box(s))),
);
group.bench_with_input(
format!("iterative/{}", size),
size,
|b, &s| b.iter(|| fibonacci_iterative(black_box(s))),
);
}
group.finish();
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
# Run benchmarks
cargo bench
# Run specific benchmark
cargo bench -- "fib 20"
Workspaces
Workspaces let you manage multiple related packages together:
my_workspace/
├── Cargo.toml # Workspace root
├── app/
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
├── core/
│ ├── Cargo.toml
│ └── src/
│ └── lib.rs
└── utils/
├── Cargo.toml
└── src/
└── lib.rs
# Root Cargo.toml
[workspace]
members = [
"app",
"core",
"utils",
]
# Shared dependencies (all members use the same version)
[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
# app/Cargo.toml
[package]
name = "app"
version = "0.1.0"
edition = "2024"
[dependencies]
core = { path = "../core" }
utils = { path = "../utils" }
serde.workspace = true # Use workspace version
tokio.workspace = true
# core/Cargo.toml
[package]
name = "core"
version = "0.1.0"
edition = "2024"
[dependencies]
utils = { path = "../utils" }
serde.workspace = true
# Build everything
cargo build
# Run a specific package
cargo run -p app
# Test everything
cargo test --workspace
# Test a specific package
cargo test -p core
Workspaces share a single target/ directory and Cargo.lock, saving compile time and ensuring consistent dependency versions.
Documentation
Writing Documentation
//! # My Library
//!
//! `mylib` provides utilities for working with data.
//! This is a module-level doc comment.
/// A configuration for the application.
///
/// # Examples
///
/// ```
/// use mylib::Config;
///
/// let config = Config::new("app", 8080);
/// assert_eq!(config.name(), "app");
/// assert_eq!(config.port(), 8080);
/// ```
pub struct Config {
name: String,
port: u16,
}
impl Config {
/// Creates a new configuration with the given name and port.
///
/// # Arguments
///
/// * `name` - The application name
/// * `port` - The port number to listen on
///
/// # Panics
///
/// Panics if `port` is 0.
///
/// # Examples
///
/// ```
/// use mylib::Config;
/// let config = Config::new("myapp", 3000);
/// ```
pub fn new(name: &str, port: u16) -> Self {
assert!(port > 0, "Port must be non-zero");
Config {
name: name.to_string(),
port,
}
}
/// Returns the application name.
pub fn name(&self) -> &str {
&self.name
}
/// Returns the configured port number.
pub fn port(&self) -> u16 {
self.port
}
}
# Generate and open docs
cargo doc --open
# Include private items in documentation
cargo doc --document-private-items
# Generate docs for dependencies too
cargo doc --no-deps # Exclude dependencies (faster)
Documentation Sections
| Section | Purpose |
|---|---|
# Examples | Code examples (tested automatically) |
# Panics | When the function might panic |
# Errors | When the function returns Err |
# Safety | For unsafe functions |
# Arguments | Describe parameters |
Publishing Crates
Preparing to Publish
# Cargo.toml - required fields for publishing
[package]
name = "my-awesome-crate"
version = "0.1.0"
edition = "2024" # Use the latest edition for new projects
authors = ["Your Name <you@example.com>"]
description = "A short description (required)"
license = "MIT OR Apache-2.0"
repository = "https://github.com/you/my-awesome-crate"
readme = "README.md"
keywords = ["utility", "tools"] # Max 5
categories = ["command-line-utilities"]
exclude = ["tests/*", "benches/*"] # Don't include in package
Publishing Workflow
# Log in to crates.io (need an account and API token)
cargo login your-api-token
# Check what will be published
cargo package --list
# Do a dry run
cargo publish --dry-run
# Publish for real
cargo publish
# Yank a bad version (prevent new projects from using it)
cargo yank --vers 0.1.0
# Un-yank
cargo yank --vers 0.1.0 --undo
Semantic Versioning
Follow semver for version numbers:
| Change Type | Version Bump | Example |
|---|---|---|
| Bug fix | Patch | 0.1.0 → 0.1.1 |
| New feature (backwards compatible) | Minor | 0.1.0 → 0.2.0 |
| Breaking change | Major | 0.1.0 → 1.0.0 |
Before 1.0.0: Minor version bumps can include breaking changes.
Useful Cargo Plugins
Install with cargo install:
# Expand macros to see generated code
cargo install cargo-expand
cargo expand
# Watch for changes and rebuild
cargo install cargo-watch
cargo watch -x check -x test -x run
# Show dependency tree
cargo install cargo-tree
cargo tree
# Audit dependencies for security vulnerabilities
cargo install cargo-audit
cargo audit
# Check for outdated dependencies
cargo install cargo-outdated
cargo outdated
# Remove unused dependencies
cargo install cargo-udeps
cargo +nightly udeps
# Cross-compile for other targets
cargo install cross
cross build --target x86_64-unknown-linux-musl
# Measure binary size by crate
cargo install cargo-bloat
cargo bloat --release
Popular Crates by Category
| Category | Crate | Purpose |
|---|---|---|
| Serialization | serde | Serialize/deserialize data |
| HTTP Client | reqwest | HTTP requests |
| Web Framework | axum, actix-web | Web servers |
| Async Runtime | tokio | Async I/O |
| CLI Parsing | clap | Command-line argument parsing |
| Error Handling | anyhow, thiserror | Ergonomic errors |
| Logging | tracing, log | Structured logging |
| Database | sqlx, diesel | Database access |
| Regex | regex | Regular expressions |
| Date/Time | chrono, time | Date and time handling |
| Random | rand | Random number generation |
| Testing | proptest, mockall | Property testing, mocking |
Practice Exercises
Exercise 1: Multi-Crate Workspace
Set up a workspace with a cli binary and a lib library. The binary should use the library to process data.
Exercise 2: Feature-Gated Module
Create a library with a json feature flag that conditionally enables JSON serialization support using serde.
Exercise 3: Full Test Suite
Write a library with:
- At least 5 unit tests
- 2 integration tests
- 2 doc tests
- 1 test that uses
#[should_panic] - 1 test that returns
Result
Common Mistakes
1. Forgetting to Add Features
# Won't compile if you use serde derive macros
serde = "1.0"
Fix: Enable the features you need:
serde = { version = "1.0", features = ["derive"] }
2. Test Code in Release Builds
// This helper is always compiled
pub fn test_helper() -> Vec<i32> {
vec![1, 2, 3]
}
Fix: Gate test-only code:
#[cfg(test)]
pub fn test_helper() -> Vec<i32> {
vec![1, 2, 3]
}
3. Not Using cargo check
Running cargo build every time is slow. Use cargo check for fast feedback during development - it skips code generation.
Key Takeaways
- Cargo handles building, testing, documentation, and publishing
- Use
Cargo.tomlto declare dependencies, features, and build profiles - Unit tests go in
#[cfg(test)] mod testsinside source files - Integration tests go in the
tests/directory - Doc tests verify your documentation examples actually work
- Use Criterion for reliable benchmarking
- Workspaces organize multi-crate projects with shared dependencies
- Feature flags let users opt in to optional functionality
cargo clippyandcargo fmtkeep code clean and consistent
Next Steps
In the next chapter, we'll cover idiomatic Rust patterns, common pitfalls, and best practices for writing clean, efficient Rust code.
Quick Reference
# Project management
cargo new myapp # New binary
cargo new mylib --lib # New library
cargo build --release # Optimized build
# Testing
cargo test # Run all tests
cargo test test_name # Run specific test
cargo test -- --nocapture # Show println output
# Quality
cargo fmt # Format code
cargo clippy # Lint code
cargo doc --open # Generate docs
# Dependencies
cargo add serde # Add dependency
cargo update # Update deps
cargo tree # Show dep tree
# Publishing
cargo login # Authenticate
cargo publish --dry-run # Test publish
cargo publish # Publish to crates.io
# Cargo.toml essentials
[dependencies]
crate_name = "1.0" # Simple
crate_name = { version = "1.0", features = [] } # With features
crate_name = { path = "../local" } # Local path
crate_name = { git = "https://..." } # From git
[dev-dependencies] # Test-only deps
[build-dependencies] # Build script deps
[features]
default = ["feature_a"]
feature_a = []