Some of our errors come from clap itself, such as when we validate that the garden directory exists.
❯ cargo run -- write
Compiling garden v0.1.0 (/Users/chris/github/rust-adventure/_in-progress/digital-garden)
Finished dev [unoptimized + debuginfo] target(s) in 0.96s
Running `target/debug/garden write`
error: garden directory `/Users/chris/garden` doesn't exist, or is inaccessible
Usage: garden [OPTIONS] <COMMAND>
For more information, try '--help'.
and other errors return directly from main as an Err
variant, specifically always as the io::Error
because we’ve been letting the io::Error
flow upward through the program back to the main
function.
❯ cargo run -- write
Compiling garden v0.1.0 (/rust-adventure/digital-garden)
Finished dev [unoptimized + debuginfo] target(s) in 0.44s
Running `target/debug/garden write`
Error: Custom { kind: NotFound, error: PathError { path: "/rust-adventure/digital-garden/garden_path/.tmpQAgjo.md", err: Os { code: 2, kind: NotFound, message: "No such file or directory" } } }
Really when it comes down to it, our library should care more about the errors that get returned. There is program-specific metadata that we’re losing out on by returning io::Error
instead of our own error type, which in turn means that users of our application lack that context for how to solve issues with the CLI.
Introducing miette
There are two problems to solve.
- report errors from main in a more user-friendly way
- Build up the extra context in our library errors
miette is a diagnostic crate that enables defining error types with additional context and supports reporting those errors.
We’ll add the fancy
feature as well.
❯ cargo add miette -F fancy
Updating crates.io index
Adding miette v5.10.0 to dependencies.
Features:
+ backtrace
+ backtrace-ext
+ fancy
+ fancy-no-backtrace
+ is-terminal
+ owo-colors
+ supports-color
+ supports-hyperlinks
+ supports-unicode
+ terminal_size
+ textwrap
- no-format-args-capture
- serde
Updating crates.io index
miette can be used in application code, like our main binary, as well as library code like our lib.rs, but the usage is a bit different in each case.
After installing miette, we need to change our main function’s return type to be miette::Result
.
This result alias includes the miette::Report
type as the error.
fn main() -> miette::Result<()> {
...
}
Compiling leads us to the only error we need to fix.
error[E0308]: mismatched types
--> src/main.rs:70:13
|
41 | fn main() -> miette::Result<()> {
| ------------------ expected `Result<(), ErrReport>` because of return type
...
70 | garden::write(garden_path, title)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Result<(), Report>`, found `Result<(), Error>`
|
= note: expected enum `Result<_, ErrReport>`
found enum `Result<_, std::io::Error>`
Our garden::write
function is returning a std::io:Error
, not the ErrReport
that miette seems to be expecting.
That’s because we need to turn the io::Error
into a diagnostic.
Bring the IntoDiagnostic
trait into scope.
use miette::IntoDiagnostic;
and make use of it to transform our garden io::Error
.
garden::write(garden_path, title).into_diagnostic()
The error display for the clap-handled errors doesn’t change because they exit the application themselves.
❯ cargo run -- write
Compiling garden v0.1.0 (/rust-adventure/digital-garden)
Finished dev [unoptimized + debuginfo] target(s) in 0.57s
Running `target/debug/garden write`
error: garden directory `/Users/chris/garden` doesn't exist, or is inaccessible
Usage: garden [OPTIONS] <COMMAND>
For more information, try '--help'.
but the errors from our library function start to show up differently.
❯ cargo run -- write
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/garden write`
Error: × No such file or directory (os error 2) at path "/rust-adventure/digital-garden/garden_path/.tmpeD314.md"
This gets us started, but there’s more to do to realize the full capability of miette.
Introducing thiserror
miette is just one of the crates that we can use to improve our errors. When it comes to our library crate we want to define our own error type that we can expand later to include more information.
The crate we’re going to use to define our own error is called thiserror
.
❯ cargo add thiserror
Updating crates.io index
Adding thiserror v1.0.44 to dependencies.
Our custom error is going to be an enum that contains everything that can go wrong in the write
function. To make this work, we need access to miette’s Diagnostic
derive macro as well as thiserror’s Error
derive macro.
use miette::Diagnostic;
use thiserror::Error;
Then we can start defining our error. I’ve named the enum GardenVarietyError
and it has a single variant to start with.
Since most of our errors are io::Error
, the easiest way to get started is to make space for that io::Error
to propagate into our new error type.
#[derive(Error, Diagnostic, Debug)]
pub enum GardenVarietyError {
#[error(transparent)]
#[diagnostic(code(garden::io_error))]
IoError(#[from] std::io::Error),
}
First, the Error
derive macro. This is what allows us to use the error(transparent)
helper as well as the #[from]
helper.
The error
helper is usually used to define the error message associated with this error. In this case, we’re propagating an already existing error type with its own messages, so we chose to use transparent
to pass through to the underlying io::Error
for that funcitonality.
We still need a way to make an io::Error
into a GardenVarietyError
. Remember that ?
can handle that for us if we have a From
trait implementation (we covered this earlier in this workshop).
The #[from]
helper generates that From
trait implementation for us!
This means that when we use ?
on an io::Error
it automatically gets turned into a GardenVarietyError::IoError()
.
This mostly works, but while the PersistError
we’ve previously covered has the relevant implementation to convert to an io::Error
, it doesn’t have one for our custom error.
error[E0277]: `?` couldn't convert the error to `GardenVarietyError`
--> src/lib.rs:26:16
|
21 | ) -> miette::Result<(), GardenVarietyError> {
| -------------------------------------- expected `GardenVarietyError` because of this
...
26 | .keep()?;
| ^ the trait `From<tempfile::file::PersistError>` is not implemented for `GardenVarietyError`
|
= note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
= help: the trait `From<std::io::Error>` is implemented for `GardenVarietyError`
= note: required for `Result<(), GardenVarietyError>` to implement `FromResidual<Result<Infallible, tempfile::file::PersistError>>`
We’ll make our second error variant: TempfileKeepError
. with a #[from]
helper to autogenerate the From
implementation for PersistError
.
pub enum GardenVarietyError {
#[error(transparent)]
#[diagnostic(code(garden::io_error))]
IoError(#[from] std::io::Error),
#[error("failed to keep tempfile: {0}")]
#[diagnostic(code(garden::tempfile_keep_error))]
TempfileKeepError(#[from] tempfile::PersistError),
}
Now this type does depend on the PersistError
from the tempfile
crate, so to access that module path we need to add tempfile
.
❯ cargo add tempfile
Updating crates.io index
Adding tempfile v3.7.0 to dependencies.
Features:
- nightly
We also chose to make our own error message this time instead of using transparent
.
The syntax used here is like a formatting string with some helpful shortcuts, so 0
is roughly equivalent to self.0
when the error is a TempfileKeepError
.
This is good enough for our application to compile and run, so we’ll leave the error enumeration there for now. That is: we can go deeper, but this is a good start.
WrapErr
miette also offers us the ability to wrap our error types with more context. Now that our garden::write
function returns an error that has Diagnostic
derived on it, we can wrap that error with additional context.
Bring miette::Context
into scope in main.rs
.
use miette::Context;
then we can use wrap_err
to add some more context to our error.
garden::write(garden_path, title).wrap_err("garden::write")
Now, when we run into an error we see a fuller diagnostic output, including a trail of context leading to the problem.
❯ cargo run -- write
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/garden write`
Error: garden::io_error
× garden::write
╰─▶ No such file or directory (os error 2) at path "/Users/chris/bad_garden_path/.tmpxbFqS.md"
Diagnostics and error codes
We also see error codes: garden::io_error
.
These come from the Diagnostic
derive macro and specifically the #[diagnostic(code(garden::io_error))]
helper on our error variants.
This helps in identifying specific errors, and can even link to URLs describing different issues in the docs.
Isolating more errors
We have a lot of io::Error
s but its kind of hard to tell where they come from inside of write
.
Let’s add a new variant that also contains an io::Error
called TempfileCreationError
.
#[derive(Error, Diagnostic, Debug)]
pub enum GardenVarietyError {
#[error(transparent)]
#[diagnostic(code(garden::io_error))]
IoError(#[from] std::io::Error),
#[error("failed to create tempfile: {0}")]
#[diagnostic(code(garden::tempfile_create_error))]
TempfileCreationError(std::io::Error),
#[error("failed to keep tempfile: {0}")]
#[diagnostic(code(garden::tempfile_keep_error))]
TempfileKeepError(#[from] tempfile::PersistError),
}
Then where we try to create the tempfile, we can map_err
to specifically turn this io::Error
into a TempfileCreationError
instead of letting the From
implementation take care of the conversion to IoError
.
let (mut file, filepath) = Builder::new()
.suffix(".md")
.rand_bytes(5)
.tempfile_in(&garden_path)
.map_err(|e| {
GardenVarietyError::TempfileCreationError(e)
})?
.keep()?;
If we force the error to happen (by, for example, replacing &garden_path
with &"garden_path"
, which doesn’t exist), then we see the new error code (garden::tempfile_create_error
) and the new error message: failed to create tempfile
.
❯ cargo run -- write
Finished dev [unoptimized + debuginfo] target(s) in 0.03s
Running `target/debug/garden write`
Error: garden::tempfile_create_error
× garden::write
╰─▶ failed to create tempfile: No such file or directory (os error 2) at path "/Users/chris/garden_path/.tmpq2W3s.md"
Including more information, and help
Upgrade our GardenVarietyError
to contain the TempfileReadError
, which we model as a struct instead of a tuple this time.
We set up an error message as usual, using field names instead of tuple indices.
The new piece here, is the addition of help text.
#[derive(Error, Diagnostic, Debug)]
pub enum GardenVarietyError {
#[error(transparent)]
#[diagnostic(code(garden::io_error))]
IoError(#[from] std::io::Error),
#[error("failed to create tempfile: {0}")]
#[diagnostic(code(garden::tempfile_create_error))]
TempfileCreationError(std::io::Error),
#[error("failed to keep tempfile: {0}")]
#[diagnostic(code(garden::tempfile_keep_error))]
TempfileKeepError(#[from] tempfile::PersistError),
#[error("Unable to read tempfile after passing edit control to user:\ntempfile: {filepath}\n{io_error}")]
#[diagnostic(
code(garden::tempfile_read_error),
help("Make sure your editor isn't moving the file away from the temporary location")
)]
TempfileReadError {
filepath: PathBuf,
io_error: std::io::Error,
},
}
We need to map the io::Error
into our TempfileReadError
just like before. This time we add the additional filepath
information as well as the original io::Error
.
let contents =
fs::read_to_string(&filepath).map_err(|e| {
GardenVarietyError::TempfileReadError {
filepath: filepath.clone(),
io_error: e,
}
})?;
This error message is most likely to happen when the user’s text editor moves the file while editing it, so we can reproduce it by making that happen.
Kick off the write
command and find the file name that the CLI opens, then delete that file and close your editor tab without saving.
❯ rm /Users/chris/garden/.tmpMGOuT.md
❯ cargo run -- write
Compiling garden v0.1.0 (/rust-adventure/digital-garden)
Finished dev [unoptimized + debuginfo] target(s) in 1.00s
Running `target/debug/garden write`
Error: garden::tempfile_read_error
× garden::write
╰─▶ Unable to read tempfile after passing edit control to user:
tempfile: /Users/chris/garden/.tmpMGOuT.md
No such file or directory (os error 2)
help: Make sure your editor isn't moving the file away from the temporary location
Now we get the garden::tempfile_read_error
code, the garden::write
context, the error message we’ve defined, the tempfile we were looking for, as well as the io::Error
that originally occurred.
Then we also get help text that suggests the potential fix to the user.
Errors
Errors have varying levels of importance. Sometimes you know they aren’t going to happen and you can .unwrap()
, other times we can use a tool like clap
to report validation errors.
Further, we can return errors from functions using Result
s with different kinds of errors. In our binary, we want to display the errors to the user so we convert into a miette report, but in our library we want to build out a custom error that represents how our application can actually fail, so we use thiserror.
Custom errors can be as complex or simple as you want. We started out with what was effectively a wrapper for any io::Error
, and moved into adding more context to the errors we felt needed more attention.
Overall, you get to choose how to handle errors, and Rust has a wide variety of tools that span everything from “ignoring them” to “full being able to match on what happened”.