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

the error said "no such file" and not which one

Adding context to errors in Rust by hand, because a bare io error tells you what went wrong but never which file it was trying to open.

A monitor filled with terminal output and code

The worst error message I ship regularly in Rust is No such file or directory. Not because it is wrong, but because it does not say which file. The ? operator is wonderful at propagating a std::io::Error up the stack, and absolutely useless at telling you the path that caused it, because the path was a local variable three functions down and it is long gone by the time the error reaches main.

This is the gap I spend the most effort filling, and there is no crate that just hands me the answer yet, so I build the context by hand. Here is how I have settled on doing it.

the problem with raw propagation

Consider a config loader:

fn load(path: &str) -> Result<Config, std::io::Error> {
    let text = std::fs::read_to_string(path)?;
    // parse...
}

If read_to_string fails, the io::Error it returns knows the kind of failure but has thrown away the path. Your user sees Error: No such file or directory (os error 2) and now both of you are guessing. On a tidy day there is one config file. On a real day there are six, loaded from three directories, and the error is a treasure hunt.

adding context, the manual way

The trick is to attach a human sentence at each layer where you know something the lower layer did not. Without a crate, the cheapest version is map_err:

fn load(path: &str) -> Result<Config, String> {
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("reading config {}: {}", path, e))?;
    parse(&text)
        .map_err(|e| format!("parsing config {}: {}", path, e))
}

Now the message reads reading config /etc/app/prod.toml: No such file or directory, which is a complete sentence a half-asleep person can act on at three in the morning. The downside is obvious: you have collapsed everything to String, so you cannot match on the error type any more, and you have lost the original error's source() chain. For a binary that is an acceptable trade. For a library it is not.

A terminal showing a stack of error messages

a small extension trait so it reads nicely

The map_err(|e| format!(...)) dance is noisy when it appears on every fallible line. So I keep a tiny extension trait in my projects that lets me write .context(...) instead, which reads far better and keeps the original error around as the cause:

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

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

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

impl Error for Context {
    fn source(&self) -> Option<&(dyn Error + 'static)> {
        Some(self.source.as_ref())
    }
}

trait ResultExt<T> {
    fn context(self, msg: &str) -> Result<T, Box<dyn Error + 'static>>;
}

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

With that in scope the loader becomes:

fn load(path: &str) -> Result<Config, Box<dyn Error>> {
    let text = std::fs::read_to_string(path)
        .context(&format!("reading config {}", path))?;
    parse(&text).context("parsing config")
}

And crucially, because Context implements source(), the original io error is still hanging off the chain. A small loop at the top can walk it and print the whole story:

fn main() {
    if let Err(e) = run() {
        eprintln!("error: {}", e);
        let mut cause = e.source();
        while let Some(c) = cause {
            eprintln!("  caused by: {}", c);
            cause = c.source();
        }
        std::process::exit(1);
    }
}

Now you get the full chain: the high-level "reading config /etc/app/prod.toml", then "caused by: No such file or directory". That is the output I actually want, and there is currently no single crate I can cargo add to get it without writing the trait above.

I copy this ResultExt between projects more or less verbatim. It is forty lines and it earns its keep on the first 3am page, because the error finally tells you both what failed and what it was doing at the time. The day someone ships a polished crate that bundles the boxed error, the .context() method, and the cause-chain printing in one go, I will delete this file with a smile. For now the manual version is small enough that I do not mind, and it has cured my most common bad message: the file error that would not name the file.