This is Part 6 — the final part of the Core Rust Concepts series.
- Part 1 — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
- Part 2 — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
- Part 3 — Macros, Modules, Testing, Unsafe Rust, FFI
- Part 4 — Threads, Channels, Send/Sync, Mutex, Atomics, clap
- Part 5 — dyn Trait, Deref, Drop, Custom Iterators, Axum REST API
Table of Contents
- Serde — Serialization Deep Dive
- Error Handling with thiserror and anyhow
- Benchmarking with Criterion
- Workspaces & Multi-Crate Projects
- Writing a Real-World CLI + Library Crate
30. Serde — Serialization Deep Dive
serde is Rust's de-facto serialization framework. It supports JSON, TOML, YAML, MessagePack, Bincode, and dozens of other formats — all from the same #[derive] annotations.
Setup
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
Basic serialize / deserialize
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
active: bool,
}
fn main() {
let user = User {
id: 1,
name: String::from("Alice"),
email: String::from("alice@example.com"),
active: true,
};
// Struct → JSON string
let json = serde_json::to_string_pretty(&user).unwrap();
println!("{json}");
// JSON string → Struct
let json_str = r#"{"id":2,"name":"Bob","email":"bob@example.com","active":false}"#;
let parsed: User = serde_json::from_str(json_str).unwrap();
println!("{:?}", parsed);
}
Output:
{
"id": 1,
"name": "Alice",
"email": "alice@example.com",
"active": true
}
Field attributes
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Article {
#[serde(rename = "article_id")]
id: u32,
title: String,
#[serde(skip_serializing_if = "Option::is_none")]
subtitle: Option<String>,
#[serde(default)]
published: bool,
#[serde(skip)]
internal_notes: String, // never included in JSON
#[serde(rename = "created_at")]
created: String,
}
fn main() {
let article = Article {
id: 42,
title: String::from("Learning Rust"),
subtitle: None, // skipped in output
published: true,
internal_notes: String::from("draft"), // skipped
created: String::from("2026-01-01"),
};
println!("{}", serde_json::to_string_pretty(&article).unwrap());
}
Output:
{
"article_id": 42,
"title": "Learning Rust",
"published": true,
"created_at": "2026-01-01"
}
Enums with serde
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
UserCreated { user_id: u32, name: String },
OrderPlaced { order_id: u32, total: f64 },
PageViewed(String),
}
fn main() {
let events = vec![
Event::UserCreated { user_id: 1, name: String::from("Alice") },
Event::OrderPlaced { order_id: 99, total: 49.95 },
Event::PageViewed(String::from("/home")),
];
for e in &events {
println!("{}", serde_json::to_string(e).unwrap());
}
}
Output:
{"type":"UserCreated","data":{"user_id":1,"name":"Alice"}}
{"type":"OrderPlaced","data":{"order_id":99,"total":49.95}}
{"type":"PageViewed","data":"/home"}
Custom serialize / deserialize
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug)]
struct Rgb(u8, u8, u8);
impl Serialize for Rgb {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&format!("#{:02X}{:02X}{:02X}", self.0, self.1, self.2))
}
}
impl<'de> Deserialize<'de> for Rgb {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
let hex = s.trim_start_matches('#');
let r = u8::from_str_radix(&hex[0..2], 16).map_err(serde::de::Error::custom)?;
let g = u8::from_str_radix(&hex[2..4], 16).map_err(serde::de::Error::custom)?;
let b = u8::from_str_radix(&hex[4..6], 16).map_err(serde::de::Error::custom)?;
Ok(Rgb(r, g, b))
}
}
fn main() {
let color = Rgb(255, 128, 0);
let json = serde_json::to_string(&color).unwrap();
println!("{json}"); // "#FF8000"
let back: Rgb = serde_json::from_str(&json).unwrap();
println!("{:?}", back); // Rgb(255, 128, 0)
}
Working with dynamic JSON (serde_json::Value)
use serde_json::{json, Value};
fn main() {
// Build JSON with the json! macro
let payload = json!({
"name": "Alice",
"scores": [98, 87, 95],
"meta": { "verified": true }
});
// Navigate dynamically
println!("{}", payload["name"]); // "Alice"
println!("{}", payload["scores"][0]); // 98
println!("{}", payload["meta"]["verified"]); // true
// Merge two JSON objects
let mut base: Value = json!({ "a": 1, "b": 2 });
let extra: Value = json!({ "b": 99, "c": 3 });
if let (Value::Object(base_map), Value::Object(extra_map)) = (&mut base, extra) {
base_map.extend(extra_map);
}
println!("{}", serde_json::to_string_pretty(&base).unwrap());
// { "a": 1, "b": 99, "c": 3 }
}
31. Error Handling with thiserror and anyhow
Rust's Result<T, E> is powerful but verbose when you have many error types. Two crates solve this cleanly — thiserror for library authors, anyhow for application code.
[dependencies]
thiserror = "1"
anyhow = "1"
thiserror — define your own error types
use thiserror::Error;
#[derive(Debug, Error)]
enum AppError {
#[error("user not found: id={0}")]
UserNotFound(u32),
#[error("database error: {0}")]
Database(#[from] std::io::Error),
#[error("invalid email: {email}")]
InvalidEmail { email: String },
#[error("permission denied for user {user_id} on resource {resource}")]
PermissionDenied { user_id: u32, resource: String },
}
fn find_user(id: u32) -> Result<String, AppError> {
if id == 0 {
return Err(AppError::UserNotFound(id));
}
Ok(format!("user_{id}"))
}
fn validate_email(email: &str) -> Result<(), AppError> {
if !email.contains('@') {
return Err(AppError::InvalidEmail { email: email.to_string() });
}
Ok(())
}
fn main() {
match find_user(0) {
Ok(u) => println!("found: {u}"),
Err(e) => println!("error: {e}"), // "error: user not found: id=0"
}
if let Err(e) = validate_email("notanemail") {
println!("{e}"); // "invalid email: notanemail"
}
}
anyhow — ergonomic error handling in applications
use anyhow::{anyhow, bail, Context, Result};
use std::fs;
fn read_config(path: &str) -> Result<String> {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read config from '{path}'"))?;
if content.is_empty() {
bail!("config file '{path}' is empty");
}
Ok(content)
}
fn parse_port(s: &str) -> Result<u16> {
s.parse::<u16>()
.map_err(|_| anyhow!("'{}' is not a valid port number (0–65535)", s))
}
fn main() -> Result<()> {
// anyhow::Result as the return type of main — shows a clean error message
let config = read_config("config.toml")
.context("startup failed")?;
println!("config loaded: {} bytes", config.len());
let port = parse_port("8080")?;
println!("listening on port {port}");
Ok(())
}
thiserror vs anyhow — when to use which
thiserror |
anyhow |
|
|---|---|---|
| Best for | Libraries | Applications / binaries |
| Error type | Typed, structured enum
|
Opaque anyhow::Error
|
| Callers can match on variants | ✅ | ❌ |
| Context chaining | Manual |
.context() built-in |
| Boilerplate | Low | Minimal |
Combining both
// In your library: typed errors with thiserror
#[derive(Debug, thiserror::Error)]
pub enum DbError {
#[error("connection failed: {0}")]
Connection(String),
#[error("query failed: {0}")]
Query(String),
}
// In your binary: use anyhow to wrap everything
fn run() -> anyhow::Result<()> {
let result: Result<(), DbError> = Err(DbError::Connection("timeout".into()));
result?; // DbError converts into anyhow::Error automatically
Ok(())
}
32. Benchmarking with Criterion
criterion is the standard Rust benchmarking library. It runs each benchmark many times, measures variance, detects regressions, and generates HTML reports.
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "my_benchmark"
harness = false
Basic benchmark
// 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_iter(n: u64) -> u64 {
let (mut a, mut b) = (0u64, 1u64);
for _ in 0..n { (a, b) = (b, a + b); }
a
}
fn bench_fibonacci(c: &mut Criterion) {
let mut group = c.benchmark_group("fibonacci");
group.bench_function("recursive n=20", |b| {
b.iter(|| fibonacci(black_box(20)))
});
group.bench_function("iterative n=20", |b| {
b.iter(|| fibonacci_iter(black_box(20)))
});
group.finish();
}
criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
cargo bench
Sample output:
fibonacci/recursive n=20 time: [123.45 µs 124.12 µs 124.89 µs]
fibonacci/iterative n=20 time: [ 1.23 ns 1.24 ns 1.25 ns]
Performance has improved: iterative is 100000x faster.
Benchmarking with input sizes
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
fn sum_vec(v: &[i32]) -> i32 { v.iter().sum() }
fn bench_sum(c: &mut Criterion) {
let mut group = c.benchmark_group("vec_sum");
for size in [100, 1_000, 10_000, 100_000] {
let data: Vec<i32> = (0..size).collect();
group.bench_with_input(
BenchmarkId::new("sum", size),
&data,
|b, v| b.iter(|| sum_vec(black_box(v))),
);
}
group.finish();
}
criterion_group!(benches, bench_sum);
criterion_main!(benches);
What black_box does
black_box(x) prevents the compiler from optimizing away the computation. Without it, the compiler might realize the result is unused and eliminate the benchmark entirely, giving you falsely fast timings.
// ❌ Compiler may optimize this to nothing
b.iter(|| fibonacci(20));
// ✅ Forces the computation to actually run
b.iter(|| fibonacci(black_box(20)));
33. Workspaces & Multi-Crate Projects
As projects grow, you split them into multiple crates in a Cargo workspace. All crates share one Cargo.lock and one target/ directory — so they share compiled dependencies and build faster.
Workspace layout
my_project/
├── Cargo.toml ← workspace root
├── crates/
│ ├── core/ ← shared library
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── lib.rs
│ ├── cli/ ← binary using core
│ │ ├── Cargo.toml
│ │ └── src/
│ │ └── main.rs
│ └── api/ ← web server using core
│ ├── Cargo.toml
│ └── src/
│ └── main.rs
Root Cargo.toml
# Cargo.toml (workspace root)
[workspace]
members = [
"crates/core",
"crates/cli",
"crates/api",
]
resolver = "2"
# Shared dependency versions across all crates
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
anyhow = "1"
thiserror = "1"
core/Cargo.toml
[package]
name = "core"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
cli/Cargo.toml
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
[dependencies]
core = { path = "../core" }
anyhow = { workspace = true }
clap = { version = "4", features = ["derive"] }
Sharing types across crates
// crates/core/src/lib.rs
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: u32,
pub name: String,
pub email: String,
}
#[derive(Debug, Error)]
pub enum CoreError {
#[error("user not found: {0}")]
NotFound(u32),
#[error("invalid input: {0}")]
InvalidInput(String),
}
pub fn find_user(id: u32) -> Result<User, CoreError> {
if id == 0 {
return Err(CoreError::NotFound(id));
}
Ok(User { id, name: format!("user_{id}"), email: format!("user{id}@example.com") })
}
// crates/cli/src/main.rs
use clap::Parser;
use core::{find_user, CoreError};
#[derive(Parser)]
struct Cli {
#[arg(short, long)]
id: u32,
}
fn main() {
let cli = Cli::parse();
match find_user(cli.id) {
Ok(user) => println!("Found: {} <{}>", user.name, user.email),
Err(CoreError::NotFound(id)) => eprintln!("No user with id {id}"),
Err(e) => eprintln!("Error: {e}"),
}
}
Useful workspace commands
# Build all crates
cargo build --workspace
# Test all crates
cargo test --workspace
# Run a specific crate's binary
cargo run -p cli -- --id 42
# Check all without building
cargo check --workspace
# Lint everything
cargo clippy --workspace
# Format everything
cargo fmt --all
34. Writing a Real-World CLI + Library Crate
Putting it all together — a filestat tool that analyzes text files and exposes its logic as a reusable library.
Library: crates/filestat-core/src/lib.rs
use serde::Serialize;
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum StatError {
#[error("could not read file '{path}': {source}")]
Io { path: String, #[source] source: std::io::Error },
#[error("file '{0}' is empty")]
Empty(String),
}
#[derive(Debug, Serialize)]
pub struct FileStats {
pub path: String,
pub lines: usize,
pub words: usize,
pub chars: usize,
pub unique_words: usize,
pub top_words: Vec<(String, usize)>,
}
pub fn analyze(path: &str) -> Result<FileStats, StatError> {
let content = std::fs::read_to_string(path)
.map_err(|e| StatError::Io { path: path.to_string(), source: e })?;
if content.trim().is_empty() {
return Err(StatError::Empty(path.to_string()));
}
let lines = content.lines().count();
let chars = content.chars().count();
let words: Vec<&str> = content
.split_whitespace()
.collect();
let word_count = words.len();
let mut freq: HashMap<String, usize> = HashMap::new();
for word in &words {
let clean = word.to_lowercase()
.trim_matches(|c: char| !c.is_alphanumeric())
.to_string();
if !clean.is_empty() {
*freq.entry(clean).or_insert(0) += 1;
}
}
let unique_words = freq.len();
let mut top_words: Vec<(String, usize)> = freq.into_iter().collect();
top_words.sort_by(|a, b| b.1.cmp(&a.1));
top_words.truncate(5);
Ok(FileStats {
path: path.to_string(),
lines,
words: word_count,
chars,
unique_words,
top_words,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_stats() {
use std::io::Write;
let mut f = tempfile::NamedTempFile::new().unwrap();
writeln!(f, "hello world hello rust world world").unwrap();
let stats = analyze(f.path().to_str().unwrap()).unwrap();
assert_eq!(stats.lines, 1);
assert_eq!(stats.words, 6);
assert_eq!(stats.unique_words, 3);
assert_eq!(stats.top_words[0].0, "world");
assert_eq!(stats.top_words[0].1, 3);
}
}
CLI: crates/filestat-cli/src/main.rs
use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use filestat_core::{analyze, StatError};
#[derive(ValueEnum, Clone, Debug)]
enum OutputFormat {
Text,
Json,
}
#[derive(Parser)]
#[command(name = "filestat", about = "Analyze text file statistics")]
struct Cli {
/// Files to analyze
files: Vec<String>,
/// Output format
#[arg(short, long, value_enum, default_value = "text")]
format: OutputFormat,
/// Show top N words (default: 5)
#[arg(long, default_value_t = 5)]
top: usize,
}
fn main() -> Result<()> {
let cli = Cli::parse();
if cli.files.is_empty() {
anyhow::bail!("no files provided. Use: filestat <file1> [file2 ...]");
}
for path in &cli.files {
match analyze(path) {
Ok(stats) => match cli.format {
OutputFormat::Json => {
println!("{}", serde_json::to_string_pretty(&stats)
.context("failed to serialize stats")?);
}
OutputFormat::Text => {
println!("── {} ──", stats.path);
println!(" Lines: {}", stats.lines);
println!(" Words: {}", stats.words);
println!(" Characters: {}", stats.chars);
println!(" Unique words: {}", stats.unique_words);
println!(" Top {} words:", cli.top);
for (word, count) in stats.top_words.iter().take(cli.top) {
println!(" {:20} {}", word, count);
}
}
},
Err(StatError::Empty(p)) => eprintln!("skipping '{p}': file is empty"),
Err(e) => eprintln!("error: {e}"),
}
}
Ok(())
}
Running it:
$ cargo run -p filestat-cli -- README.md --format text
── README.md ──
Lines: 42
Words: 381
Characters: 2104
Unique words: 198
Top 5 words:
the 24
rust 18
and 15
a 12
to 11
$ cargo run -p filestat-cli -- README.md --format json
{
"path": "README.md",
"lines": 42,
"words": 381,
...
}
Wrapping Up Part 6
| Concept | What it gives you |
|---|---|
| serde | Zero-boilerplate serialization to any format |
| thiserror | Typed, ergonomic library error enums |
| anyhow | Context-rich error propagation in apps |
| criterion | Statistical, regression-detecting benchmarks |
| Workspaces | Multi-crate projects with shared dependencies |
| CLI + Library | Separation of logic and interface, reusable core |
The Complete Series
| Part | Topics |
|---|---|
| Part 1 | Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching |
| Part 2 | Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await |
| Part 3 | Macros, Modules, Cargo, Testing, Unsafe, FFI |
| Part 4 | Threads, Channels, Send/Sync, Mutex, Atomics, clap |
| Part 5 | dyn Trait, Deref, Drop, Custom Iterators, Axum REST API |
| Part 6 | Serde, thiserror, anyhow, Criterion, Workspaces, CLI + Library |
Where to Go Next
You've covered the full breadth of Rust — here's what to explore next:
- The Rust Book — the official, free, comprehensive reference
- Rustlings — hands-on exercises
- Rust by Example — example-driven learning
- Jon Gjengset on YouTube — deep dives into advanced Rust
- Blessed.rs — curated list of recommended crates
- This Week in Rust — weekly newsletter
Thanks for following the entire series! If it helped you, drop a ❤️ on each part and share with someone learning Rust. See you in the next one! 🦀
Top comments (0)