For your eyes only

For your eyes only

(photo by Jeff Jackowski, licensed under Creative Commons)

Keyboard not found. Press F1 to continue.
—Error message

“It doesn’t work” is the least helpful bug report you could ever get, because it tells you something’s wrong, but not what. And that goes both ways: when our programs report errors to users, they need to say more than just something like “error” or ”failed”.

Oddly enough, though, most programmers don’t give a great deal of thought to error messages, or how they’re presented to users. Worse, they often don’t even anticipate that an error could happen, and so the program does something even worse than printing a meaningless error: it prints nothing at all.

In Things fall apart, we realised that our line-counting program doesn’t cope well with errors reading its input: they make it hang forever. Instead, we’d like the program to stop and report the error, so that the user can try something different.

Handling errors in count_lines

Here’s the count_lines function we have right now:

use std::io::{BufRead, Result};

pub fn count_lines(input: impl BufRead) -> Result<usize> {
    Ok(input.lines().count())
}

Clearly we can’t always return Ok() from count_lines, because it’s not always okay. Sometimes the result yielded by lines will be an error, in which case we need to stop counting and return it.

One way to do that is to unroll the iterator expression into a for loop, like this:

pub fn count_lines(input: impl BufRead) -> Result<usize> {
    let mut count = 0;
    for line in input.lines() {
        line?;
        count += 1;
    }
    Ok(count)
}

(Listing count_3)

If the result yielded by lines() is an error, we’ll catch it using the question mark operator (?):

line?;

That is, if line is the Err variant, the function returns it immediately. Otherwise, this statement has no effect, and we move on to increment the counter and continue the loop.

This passes the test that simulates a read error, so let’s see if the real program now also behaves correctly. We’ll need to update main for the new signature of count_lines, because right now we have a compile error:

`std::result::Result<usize, std::io::Error>` doesn't implement
`std::fmt::Display`

In other words, we tried to print the return value of count_lines using the println! macro, which uses the Display trait to ask the value how it should be formatted as a string. Result doesn’t implement Display, because there isn’t really a sensible default way to print a Result, so Rust requires us to implement the trait manually if that’s what we want to do.

Displaying results to users

That seems like overkill for this simple program, so let’s just use the ? formatting parameter for now, which will give us the “programmer’s view” of the value, using the Debug trait:

fn main() {
    let lines = count_lines(stdin().lock());
    println!("{lines:?} lines");
}

What does the output look like when there’s no error? Let’s see:

cargo run <src/main.rs

Ok(8) lines

Hmm. We really want just the number, not the surrounding Ok(), but let’s come back to that. Right now we’re interested in what users see when an error happens, so let’s trigger one:

cargo run <.

Err(Os { code: 21, kind: IsADirectory, message: "Is a directory" })

A bit wonky, but this is the debug format, which is intended for the programmer’s eyes only, so that’s fine.

A slightly nicer presentation

Let’s go ahead and spare the user the more excruciating details:

fn main() {
    let res = count_lines(stdin().lock());
    match res {
        Ok(lines) => println!("{lines} lines"),
        Err(e) => println!("{e}"),
    }
}

Unlike Result, Err is Display, meaning it knows how to format itself in a (relatively) user-friendly way. Here’s the output:

Is a directory (os error 21)

That’s good enough. It tells the user what’s wrong, without too much wonky syntax or detail: “Hey, it looks like you’re trying to count lines in a directory, and that doesn’t really make sense. Try something else.”

In my book The Secrets of Rust: Tools, we’ll pursue this idea further, learning how to write Rust programs that are not only robust and durable, but also friendly, useful, and flexible. The first step in that direction is knowing when to expect errors, and the second is figuring out what to do when they happen.

Standard error, and exit status

Let’s add one more refinement before we move on. It’s a useful convention for command-line programs to print their error messages to the standard error stream, not the standard output. That way, if the normal output is being redirected to some file, error messages will still go to the user’s terminal, not the file.

Also, when a program fails for some reason, it’s helpful to set the exit status to some non-zero value, indicating an error. If the program is being used in a shell script, for example, the abnormal exit status will stop the script, instead of having it continue with missing data.

So let’s make those changes to the match arm for the error case:

use std::{io::stdin, process};

use count::count_lines;

fn main() {
    let res = count_lines(stdin().lock());
    match res {
        Ok(lines) => println!("{lines} lines"),
        Err(e) => {
            eprintln!("{e}");
            process::exit(1);
        }
    }
}

(Listing count_3)

eprintln! is exactly like println!, as you might have guessed, except that it writes to standard error. process::exit causes the program to exit immediately, with the given status value, instead of waiting until it reaches the end of main.

We’ll run the command again to see if that works:

cargo run <.

Is a directory (os error 21)

echo $?

1

The shell variable $? contains the exit status of the last program you ran, so we can see that the line counter is doing the right thing here. Now we can detect problems automatically when running the program in a shell script:

cargo run <. || echo "Whoops, something went wrong"

Is a directory (os error 21)
Whoops, something went wrong

And any other kind of I/O error we encounter when reading input should also trigger the same behaviour: stop counting, print a friendly message, and exit with status 1. Very nice!

Resilience matters

You might think this is a lot of fussing for something that’s pretty unlikely to happen in real life. But, in a sense, error handling is the most important part of any program. Anyone can write the happy path! It’s what the program does when something weird, unexpected, or awkward happens that really distinguishes well-engineered software from janky hacks.

And we can’t imagine all the possible errors that could happen, so we’ve no business deriving a probability distribution for them. The best plan is to assume that, sooner or later, anything that can go wrong will go wrong, and when our software is used at scale, that will assuredly be the case.

Handling invalid input

For example, the lines iterator yields Rust strings, which are guaranteed to be valid UTF-8. But our input is just a bunch of raw, undisciplined bytes that we picked up on the street (“Don’t put those bytes in your mouth! You don’t know where they’ve been!”)

So what would happen if the user fed the program some text that isn’t UTF-8, or even some random binary file that isn’t any kind of text at all? Will it hang, crash horribly, or produce the wrong result?

We’d like to think that Rust has our back here. If the BufReader can’t parse the input bytes as UTF-8-encoded text, we feel it should yield Err. And if that’s the case, then our program should already handle that situation correctly, and terminate with an informative error message. Let’s see what happens when we pipe in a bogus byte, then:

echo '\x80' | cargo run

stream did not contain valid UTF-8

That’s a win for Rust, and a win for us! Even though we didn’t envisage this specific error situation when writing the code, we nonetheless assumed that errors of some kind are bound to happen, and allowed for them. We’ve made a good start on writing durable software.

Getting nothing done

Getting nothing done

0