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::Errors 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 Results 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”.