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

CommandPurpose
cargo buildCompile the project (debug mode)
cargo build --releaseCompile with optimizations
cargo runBuild and run the binary
cargo checkCheck for errors without building
cargo testRun all tests
cargo fmtFormat code with rustfmt
cargo clippyRun the linter
cargo doc --openGenerate and open documentation
cargo cleanRemove build artifacts
cargo updateUpdate 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

SpecifierMeaning
"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.lock to 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

MacroPurpose
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

SectionPurpose
# ExamplesCode examples (tested automatically)
# PanicsWhen the function might panic
# ErrorsWhen the function returns Err
# SafetyFor unsafe functions
# ArgumentsDescribe 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 TypeVersion BumpExample
Bug fixPatch0.1.00.1.1
New feature (backwards compatible)Minor0.1.00.2.0
Breaking changeMajor0.1.01.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
CategoryCratePurpose
SerializationserdeSerialize/deserialize data
HTTP ClientreqwestHTTP requests
Web Frameworkaxum, actix-webWeb servers
Async RuntimetokioAsync I/O
CLI ParsingclapCommand-line argument parsing
Error Handlinganyhow, thiserrorErgonomic errors
Loggingtracing, logStructured logging
Databasesqlx, dieselDatabase access
RegexregexRegular expressions
Date/Timechrono, timeDate and time handling
RandomrandRandom number generation
Testingproptest, mockallProperty 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.toml to declare dependencies, features, and build profiles
  • Unit tests go in #[cfg(test)] mod tests inside 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 clippy and cargo fmt keep 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 = []