1016-error-context — Error Context
Tutorial
The Problem
When an error bubbles up through several layers of a call stack, the raw error message often lacks enough information to diagnose the problem. "file not found" tells you what failed but not why the file was being read or what operation was in progress. Adding context at each propagation point — "loading config -> reading file -> file not found" — produces error messages that pinpoint the root cause without a debugger.
The anyhow crate provides .context("...") and .with_context(|| ...) as an extension trait on Result. This example builds the same mechanism from scratch using a wrapper struct, so the mechanics are transparent.
🎯 Learning Outcomes
ErrorWithContext struct that carries a message and a context chainContext extension trait for Result<T, ErrorWithContext>.context(str) and lazy .with_context(|| str)anyhow::Context generalises this to any error typeCode Example
#![allow(clippy::all)]
// 1016: Error Context
// Add context/backtrace to errors manually using wrapper structs
use std::fmt;
// Approach 1: Context wrapper struct
#[derive(Debug)]
struct ErrorWithContext {
message: String,
context: Vec<String>,
}
impl ErrorWithContext {
fn new(message: impl Into<String>) -> Self {
ErrorWithContext {
message: message.into(),
context: Vec::new(),
}
}
fn with_context(mut self, ctx: impl Into<String>) -> Self {
self.context.push(ctx.into());
self
}
}
impl fmt::Display for ErrorWithContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.context.is_empty() {
write!(f, "{}", self.message)
} else {
let chain: Vec<&str> = self.context.iter().rev().map(|s| s.as_str()).collect();
write!(f, "{}: {}", chain.join(" -> "), self.message)
}
}
}
impl std::error::Error for ErrorWithContext {}
// Extension trait for adding context to any Result
trait Context<T> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext>;
}
impl<T> Context<T> for Result<T, ErrorWithContext> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(ctx))
}
fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(f()))
}
}
// Low-level functions
fn read_file(path: &str) -> Result<String, ErrorWithContext> {
if path == "/missing" {
Err(ErrorWithContext::new("file not found"))
} else {
Ok("42".into())
}
}
fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
content
.parse::<i64>()
.map_err(|e| ErrorWithContext::new(format!("invalid number: {}", e)))
}
// Approach 2: Chain contexts through layers
fn load_setting(path: &str) -> Result<i64, ErrorWithContext> {
let content = read_file(path).context("reading config")?;
let value = parse_config(&content).context("parsing config")?;
Ok(value)
}
fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
load_setting(path).context("system init")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_context() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn test_single_context() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn test_nested_context() {
let result = init_system("/missing");
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
assert!(err.to_string().contains("system init"));
assert!(err.to_string().contains("reading config"));
assert!(err.to_string().contains("file not found"));
}
#[test]
fn test_success_passes_through() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn test_context_trait() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.context("layer1");
let result = result.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
}
#[test]
fn test_lazy_context() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.with_context(|| format!("dynamic context {}", 42));
let err = result.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
}Key Differences
with_context takes a closure to avoid string formatting when no error occurs; OCaml's Error.tag is lazy by default via Info.t.Context trait is implemented on Result<T, E> using a blanket impl; OCaml uses module functions.Vec and reversing on display; OCaml's Error tree is structured differently but renders similarly.anyhow vs custom**: Production Rust uses anyhow::Context which works with any error type via Box<dyn Error>; OCaml's Or_error is the equivalent standard library choice.OCaml Approach
OCaml's Base.Error type carries a lazy tree of error messages. The error_s and tag functions annotate errors with context:
let with_context label f =
match f () with
| Ok v -> Ok v
| Error e -> Error (Error.tag e ~tag:label)
Libraries like Core_kernel provide Or_error.tag for exactly this pattern. Unlike Rust's struct approach, OCaml's Error is a lazy Info.t tree that is only rendered when displayed.
Full Source
#![allow(clippy::all)]
// 1016: Error Context
// Add context/backtrace to errors manually using wrapper structs
use std::fmt;
// Approach 1: Context wrapper struct
#[derive(Debug)]
struct ErrorWithContext {
message: String,
context: Vec<String>,
}
impl ErrorWithContext {
fn new(message: impl Into<String>) -> Self {
ErrorWithContext {
message: message.into(),
context: Vec::new(),
}
}
fn with_context(mut self, ctx: impl Into<String>) -> Self {
self.context.push(ctx.into());
self
}
}
impl fmt::Display for ErrorWithContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.context.is_empty() {
write!(f, "{}", self.message)
} else {
let chain: Vec<&str> = self.context.iter().rev().map(|s| s.as_str()).collect();
write!(f, "{}: {}", chain.join(" -> "), self.message)
}
}
}
impl std::error::Error for ErrorWithContext {}
// Extension trait for adding context to any Result
trait Context<T> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext>;
fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext>;
}
impl<T> Context<T> for Result<T, ErrorWithContext> {
fn context(self, ctx: impl Into<String>) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(ctx))
}
fn with_context(self, f: impl FnOnce() -> String) -> Result<T, ErrorWithContext> {
self.map_err(|e| e.with_context(f()))
}
}
// Low-level functions
fn read_file(path: &str) -> Result<String, ErrorWithContext> {
if path == "/missing" {
Err(ErrorWithContext::new("file not found"))
} else {
Ok("42".into())
}
}
fn parse_config(content: &str) -> Result<i64, ErrorWithContext> {
content
.parse::<i64>()
.map_err(|e| ErrorWithContext::new(format!("invalid number: {}", e)))
}
// Approach 2: Chain contexts through layers
fn load_setting(path: &str) -> Result<i64, ErrorWithContext> {
let content = read_file(path).context("reading config")?;
let value = parse_config(&content).context("parsing config")?;
Ok(value)
}
fn init_system(path: &str) -> Result<i64, ErrorWithContext> {
load_setting(path).context("system init")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_context() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn test_single_context() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn test_nested_context() {
let result = init_system("/missing");
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
assert!(err.to_string().contains("system init"));
assert!(err.to_string().contains("reading config"));
assert!(err.to_string().contains("file not found"));
}
#[test]
fn test_success_passes_through() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn test_context_trait() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.context("layer1");
let result = result.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
}
#[test]
fn test_lazy_context() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.with_context(|| format!("dynamic context {}", 42));
let err = result.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_context() {
let err = ErrorWithContext::new("oops");
assert_eq!(err.to_string(), "oops");
}
#[test]
fn test_single_context() {
let err = ErrorWithContext::new("oops").with_context("loading");
assert_eq!(err.to_string(), "loading: oops");
}
#[test]
fn test_nested_context() {
let result = init_system("/missing");
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
assert!(err.to_string().contains("system init"));
assert!(err.to_string().contains("reading config"));
assert!(err.to_string().contains("file not found"));
}
#[test]
fn test_success_passes_through() {
assert_eq!(init_system("/ok").unwrap(), 42);
}
#[test]
fn test_context_trait() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.context("layer1");
let result = result.map_err(|e| e.with_context("layer2"));
let err = result.unwrap_err();
assert_eq!(err.context.len(), 2);
}
#[test]
fn test_lazy_context() {
let result: Result<i64, ErrorWithContext> = Err(ErrorWithContext::new("base"));
let result = result.with_context(|| format!("dynamic context {}", 42));
let err = result.unwrap_err();
assert!(err.to_string().contains("dynamic context 42"));
}
}
Deep Comparison
Error Context — Comparison
Core Insight
Raw errors like "file not found" are useless without knowing which file, in which operation, at which layer. Context wrapping builds a breadcrumb trail.
OCaml Approach
context: string list accumulates breadcrumbs>>| operator adds context in pipelinesRust Approach
Vec<String> context chainContext on Result — .context("msg")?.with_context(|| format!(...))?anyhow::Context trait does exactly thisComparison Table
| Aspect | OCaml | Rust |
|---|---|---|
| Context accumulator | string list field | Vec<String> field |
| Adding context | Custom >>| operator | .context() trait method |
| Lazy context | Thunk fun () -> ... | Closure \|\| format!(...) |
| Standard library | No | No (but anyhow is de facto standard) |
| Display format | Manual String.concat | Custom Display impl |
Exercises
context_if(predicate: bool, msg: &str) combinator that only attaches context when the predicate is true.source() chain walker that prints all context levels in a numbered list.anyhow::Result and anyhow::Context from the anyhow crate and compare the implementation complexity.