<< Home

Chapter 17: Building Real Projects

It is time to turn your Rust skills into complete applications. This chapter walks through common project types and the practices that carry them from a blank repository to a shipped artifact. You will see how to structure a binary crate, add dependencies, keep configuration outside of code, test what matters, and publish with confidence.

CLI tools from zero to release

Command line tools are a great way to learn how Rust scales from small programs to robust utilities. You will start with a new binary crate, add a command line parser, handle I/O and errors, and finish by producing signed, reproducible release builds.

Project layout and cargo scaffolding

A binary project starts as a crate with a src/main.rs entry point. Keep logic in src/lib.rs where possible, so it can be tested and reused by the binary.

# Create a new binary crate
cargo new ripgrepish --bin
cd ripgrepish

# Minimal main that calls into a library function
// src/main.rs
use ripgrepish::run;

fn main() {
  if let Err(e) = run() {
    eprintln!("error: {e}");
    std::process::exit(1);
  }
}
// src/lib.rs
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
  // real logic lives here
  Ok(())
}
💡 Keeping logic in lib.rs makes it easy to expose a clean API and write unit tests without spinning up the binary each time.

Parsing arguments with clap

The clap crate provides structured parsing, validation, and usage text. Prefer derive based definitions so your help output stays in sync with your code.

// Cargo.toml (snippet)
[dependencies]
clap = { version = "4", features = ["derive"] }
// src/cli.rs
use clap::{Parser, Subcommand};

#[derive(Parser)]
#[command(author, version, about)]
pub struct Args {
  /// Search pattern
  pub pattern: String,
  /// Path to search (defaults to current directory)
  #[arg(default_value = ".")]
  pub path: String,
  #[command(subcommand)]
  pub cmd: Option<Cmd>,
}

#[derive(Subcommand)]
pub enum Cmd {
  /// Generate shell completions
  Completions { shell: String },
}
// src/main.rs (wire up)
mod cli;
use cli::Args;

fn main() {
  let args = Args::parse();
  // handle args.cmd and args.pattern …
}

I/O, errors, and logging

For pleasant diagnostics, combine anyhow or eyre with thiserror in library code and a lightweight logger in the binary. Structured logs help users and future you.

// Cargo.toml (snippet)
[dependencies]
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] }
// src/main.rs
fn main() {
  tracing_subscriber::fmt::init();
  if let Err(err) = ripgrepish::run() {
    tracing::error!("fatal error: {err:?}");
    std::process::exit(1);
  }
}

Testing, benchmarking, and releasing

Automate quality and releases. Add fast unit tests, a few integration tests in tests/, and set up CI to run across platforms. Use cargo build --release for optimized binaries and attach them to a tagged release.

# Typical flow
cargo fmt -- --check
cargo clippy -- -D warnings
cargo test
cargo build --release
# Create v1.0.0 tag and attach binaries …

Web services with axum or actix-web

Rust is a strong fit for network services. You will pick a framework, define routes and handlers, apply middleware, and serve with an async runtime. The patterns are similar across frameworks; choose based on ergonomics and ecosystem fit.

Choosing axum vs actix-web

axum sits on tokio and tower with a focus on routing and middleware composition. actix-web offers a batteries included experience with an actor model at its core. Both are production ready.

⚠️ If you depend on a specific async runtime in your wider stack, align your web framework with it to avoid mixing runtimes in one process.

Routing and handlers

Handlers are async functions that take typed extractors and return responses. Keep them small and delegate to services in your application layer.

// Cargo.toml (snippet for axum)
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
// src/main.rs (axum hello)
use axum::{routing::get, Router};

#[tokio::main]
async fn main() {
  let app = Router::new()
    .route("/health", get(health))
    .route("/api/items/…", get(list_items));
  let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.unwrap();
  axum::serve(listener, app).await.unwrap();
}

async fn health() -> &'static str { "ok" }
async fn list_items() -> String { "[]" .to_string() }

Middleware, state, and errors

Use middleware for cross cutting concerns like logging, timeouts, authentication, and compression. Share state by injecting an Arc<AppState> and use a consistent error type for clean responses.

// Shared state
use std::sync::Arc;
#[derive(Clone)]
struct AppState {
  // db pool, config, cache …
}

Serving static files and TLS

For assets, mount a static directory or place a reverse proxy in front. For TLS, terminate at a proxy or use rustls if the service will handle certificates itself.

Database access with SQLx and Diesel

Rust offers both query first and schema first approaches. SQLx executes raw SQL with compile time checking when a database URL is available. Diesel generates strongly typed queries from schema definitions.

Connecting and pooling

Create a connection pool during startup and pass it through application state. Prefer a small, bounded pool and instrument it to detect saturation.

// SQLx example
# Cargo.toml (snippet)
[dependencies]
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }

// src/db.rs
use sqlx::{Pool, Postgres};

pub type PgPool = Pool<Postgres>;

pub async fn connect(url: &str) -> Result<PgPool, sqlx::Error> {
  sqlx::postgres::PgPoolOptions::new()
    .max_connections(5)
    .connect(url)
    .await
}

Migrations

Version your schema with migrations that can be applied forward and backward. Treat the database as part of your codebase and keep migration files under version control.

# SQLx
cargo sqlx migrate add create_users
# creates migrations/…/up.sql and down.sql

Queries and type safety

With SQLx, write SQL directly and map rows to structs. With Diesel, build queries with combinators that mirror SQL and leverage Rust types for safety.

// SQLx typed fetch
use serde::Serialize;

#[derive(Serialize, sqlx::FromRow)]
struct User { id: i64, name: String }

pub async fn list(pool: &PgPool) -> Result<Vec<User>, sqlx::Error> {
  sqlx::query_as::<User>("SELECT id, name FROM users ORDER BY id")
    .fetch_all(pool)
    .await
}
💡 Enable SQLx offline mode to keep compile time checks without a live database by committing the metadata file generated from your development database.

Testing against a real database

Use ephemeral databases or schemas for integration tests. Apply migrations during test setup and tear down afterwards for isolation.

// tests/db_it.rs
#[tokio::test]
async fn creates_user() {
  // spin up test pool, run migrations, call functions …
}

Configuration and secrets

Keep configuration outside of code and separate secrets from non secret settings. Use layered sources such as defaults, configuration files, and environment variables. Do not commit secrets to version control.

Layered configuration

Load defaults first, then override with file based settings, and finally with environment variables. This order makes local development smooth while letting deployments adjust behavior.

// Example structure
#[derive(serde::Deserialize, Clone)]
pub struct Settings {
  pub host: String,
  pub port: u16,
  pub database_url: String,
}

Environment variables and .env

During development, a .env file can populate variables. In production, inject environment variables from a secret store or deployment system.

# .env
HOST=127.0.0.1
PORT=8080
DATABASE_URL=postgres://…

Secret management

Use a dedicated secret manager when available. If you must read files at runtime, mount secrets as files with strict permissions and read them at startup.

⚠️ Never log the contents of configuration structures that include secrets. Redact fields or implement a custom formatter that hides sensitive values.

Configuration validation

Validate configuration at startup and fail fast with a clear message if anything is missing or malformed. Keep the validation near the loading code for discoverability.

Packaging, versioning, and publishing

Reliable builds and clear versions help users trust your project. Package artifacts consistently, follow semantic versioning, and publish to the right channel for your audience.

Cargo.toml metadata and features

Populate package metadata and organize optional functionality with features. Good metadata improves discoverability and sets expectations for users.

# Cargo.toml (snippet)
[package]
name = "ripgrepish"
version = "1.0.0"
edition = "2021"
license = "MIT"
repository = "https://…"
description = "A tiny grep-like tool in Rust"

[features]
default = ["cli"]
cli = []
tls = []

Semantic versioning and change management

Use version numbers to communicate compatibility. Increment the patch when fixing bugs, the minor when adding compatible features, and the major when you make breaking changes. Keep a changelog that maps changes to versions.

Building and signing release artifacts

Produce release binaries for target platforms and attach checksums. Consider signing artifacts so users can verify integrity.

# Example targets
cargo build --release --target x86_64-unknown-linux-gnu
cargo build --release --target x86_64-pc-windows-msvc
cargo build --release --target aarch64-apple-darwin
shasum -a 256 target/…/ripgrepish > ripgrepish-….sha256
💡 For cross compilation, use a container that matches your target or a project like cross so your builds are reproducible on any host.

Publishing crates and binaries

If you are sharing a library, publish to crates.io. If you are shipping a CLI, create a tagged release with attached binaries and a short installation section that shows one or two commands. Provide a checksum file and instructions for verification.

# Library crate
cargo publish

# Binary release (outline)
git tag v1.0.0
git push origin v1.0.0
# attach artifacts and write release notes …

With these practices, your Rust projects move from experiments to dependable tools and services. The next chapters expand on testing strategies, performance tuning, and deployment patterns so you can operate your software with confidence.