Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

the .context() helper i keep copying between rust projects

A small extension trait that attaches human-readable context to errors as they bubble up, written by hand because the obvious crate to do this doesn't exist yet.

A terminal showing a Rust error message with a chain of causes

I had a quiet few days over the break and I finally stopped to fix a thing that's been annoying me across every small Rust tool I write. The errors my tools print are technically correct and practically useless. A program reads three files, parses two of them, and shells out once, and when it fails it prints No such file or directory. Which file? You get to guess. The boxed dyn Error approach that I default to for CLIs is lovely for getting ? to compile across libraries, but it throws away the one thing the operator at the other end actually needs, which is where in the program this happened and what it was trying to do.

What I want is to be able to write .context("while reading the config") after a fallible call and have that string carried along with the underlying error, so the final message reads like a sentence instead of a stack trace fragment. There's no crate that does just this today, or none I trust, so I keep hand-rolling it, and over the break I tidied the hand-rolled version into something I'm happy copying around.

It's an error type that holds a message and an optional source, plus an extension trait on Result:

use std::error::Error;
use std::fmt;

#[derive(Debug)]
struct Contextual {
    msg: String,
    source: Option<Box<dyn Error + 'static>>,
}

impl fmt::Display for Contextual {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "{}", self.msg)
    }
}

impl Error for Contextual {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        self.source.as_ref().map(|b| b.as_ref())
    }
}

trait Context<T> {
    fn context(self, msg: &str) -> Result<T, Contextual>;
}

impl<T, E: Error + 'static> Context<T> for Result<T, E> {
    fn context(self, msg: &str) -> Result<T, Contextual> {
        self.map_err(|e| Contextual {
            msg: msg.to_string(),
            source: Some(Box::new(e)),
        })
    }
}

The trick that makes it pleasant is implementing Error::source properly, because then a little helper that walks the source chain can print the whole story:

fn print_chain(e: &dyn Error) {
    eprintln!("error: {}", e);
    let mut cur = e.source();
    while let Some(src) = cur {
        eprintln!("  caused by: {}", src);
        cur = src.source();
    }
}

Now the call site reads the way I wanted all along:

let config = fs::read_to_string(&path)
    .context("while reading the config")?;

And a missing file prints:

error: while reading the config
  caused by: No such file or directory (os error 2)

That's the entire win, and it's a big one for something so small. The person reading the log at the wrong hour now knows exactly which step failed and what the underlying cause was, in two lines, without me having to litter the code with manual format! calls around every ?.

I'm slightly annoyed that I keep writing this. It's clearly a gap, and it's clearly the kind of thing the ecosystem fills eventually. The moment there's a well-loved crate that gives me .context() and a from-anything error type out of the box, I'll delete all thirty of these lines from every project without a second thought. Until then this lives in a snippet file and gets pasted into the top of each new tool, and honestly it's served me well enough that I've stopped resenting the paste.