A better approach to handling errors
Good to know before start
As you already used Result
, it's the preferred way to handle errors in rust applications. What if I want to introduce new error types? Unless you don't want to propagate your errors to callers, It's more idiomatic to implement Error
trait for your new error type.
An easier approach is to use thiserror crate to create new error types and to have everything ready to handle errors. We'll use anyhow crate as well. So hold this tutorial here and make yourself familiar with these two popular rust crates.
Implementation
Add crates to the project
First things first. Add thiserror
and anyhow
crates to your project. Change your Cargo.toml or use cargo add thiserror
Add ConfigParseError type and use it
Create a new module src/error.rs
. And add ConfigParseError
enum. This enum is used whenever we encounter an error while parsing config file.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigParseError {
#[error("Failed to parse config file. {0}")]
ParseFailed(String),
#[error(transparent)]
IoError(#[from] std::io::Error),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
Let's use this error practically. Here is our previous version of load_from_file
function in GitConfig
pub fn load_from_file(path: &Path) -> Result<Self, String> {
let mut config_file = File::open(path)
.map_err(|e| format!("Failed to open config file: {}, {}", path.display(), e))?;
let mut config_string = String::new();
config_file
.read_to_string(&mut config_string)
.map_err(|e| e.to_string())?;
Ok(config_string.parse()?)
}
File::open
returns std::io::Error
in the case of errors, but we mapped it to an string, because our return type was string. But now we have a better error type ConfigParseError
First, let's change the return type to ConfigParseError
:
pub fn load_from_file(path: &Path) -> Result<Self, ConfigParseError> {
Now you can easily use this:
pub fn load_from_file(path: &Path) -> Result<Self, ConfigParseError> {
let mut config_file = File::open(path)?;
Why? Because we declared IoError
in ConfigParseError
and we asked to have From<std::io::Error
implemented for us. So in the case of std::io::Error, we return ConfigParseError::IoError
. It's better to add some contexts to errors. Everything that can help us diagnose the error easier.
Here is the final Implementation of the function:
pub fn load_from_file(path: &Path) -> Result<Self, ConfigParseError> {
let mut config_file = File::open(path).context("Failed to open config file")?;
let mut config_string = String::new();
config_file
.read_to_string(&mut config_string)
.context("Failed to read config file")?;
config_string.parse()
}
You can use context
function from anyhow
to add context to the error. Similarly, we can update repository_format_version
function to use our new error type:
pub fn repository_format_version(&self) -> Result<u16, ConfigParseError> {
let core = self
.config
.get("core")
.ok_or(ConfigParseError::ParseFailed(
"Core section doesn't exist".to_string(),
))?;
match core
.get("repositoryformatversion")
.ok_or(ConfigParseError::ParseFailed(
"repositoryformatversion not found.".to_string(),
))?
.clone()
.map(|ver| ver.parse::<u16>())
.transpose()
.map_err(|e| ConfigParseError::ParseFailed(e.to_string()))?
{
Some(v) => Ok(v),
None => Err(ConfigParseError::ParseFailed(
"repositoryformatversion doesn't exist in config".to_string(),
)),
}
}
Change is_repository_format_version_valid
and from_str
functions yourself to have the new error type. Not that hard.
Add CreateRepoError error type and use it
Let's add CreateRepoError
enum to src/error.rs
:
#[derive(Debug, Error)]
pub enum CreateRepoError {
#[error("Format version is not valid")]
InvalidRepositoryFormatVersionError,
#[error("No git toplevel found in current directory/any of parents")]
NoToplevelFoundError,
#[error("Provided toplevel is not a directory.")]
TopLevelIsNotDirectory,
#[error("Provided toplevel is not empty.")]
TopLevelIsNotEmpty,
#[error(transparent)]
ConfigError(#[from] ConfigParseError),
#[error(transparent)]
UnexpectedError(#[from] anyhow::Error),
}
First we use it in TryFrom<DirectoryManager>
:
// src/repository.rs
impl TryFrom<DirectoryManager> for GitRepository {
// Change Error type to newly created enum
type Error = CreateRepoError;
fn try_from(directory_manager: DirectoryManager) -> Result<Self, Self::Error> {
let config = GitConfig::load_from_file(&directory_manager.config_file)?;
if !config.is_repository_format_version_valid()? {
// Instead of returning a string, return appropriate error
return Err(CreateRepoError::InvalidRepositoryFormatVersionError);
}
Ok(Self {
config,
directory_manager,
})
}
}
Change return type of load
, find
, and create
functions to Result<Self, CreateRepoError>
and try to make it compile. You can see the final code here.
For example, the altered code of find
is:
pub fn find(working_dir: &Path) -> Result<Self, CreateRepoError> {
match DirectoryManager::is_toplevel_directory(working_dir) {
true => GitRepository::load(working_dir),
false => {
let parent_path = working_dir
.parent()
// If parent doesn't exist we can't step back further to find the repo toplevel directory.
.ok_or(CreateRepoError::NoToplevelFoundError)?;
GitRepository::find(parent_path)
}
}
}
change the main function to respect the return type of GitRepository::create
GitRepository::create
Here is the previous version of the main. What happens if create function fails? Nothing! The app panics.
fn main() {
let command = parse_args().unwrap();
match command {
Command::Init { path } => GitRepository::create(path).unwrap(),
};
}
Let's make it more professional.
use anyhow::{Ok, Result};
use rit::{parse_args, repository::GitRepository, Command};
fn main() -> Result<()> {
let command = parse_args().unwrap();
match command {
Command::Init { path } => GitRepository::create(path)?,
};
Ok(())
}
Now try to break init
command:
$ touch /tmp/prj-dir
$ cargo run -- init /tmp/prj-dir
You must see Error: Provided toplevel is not a directory.
Please take a look at the commit changes. I also added a tiny error type for argument parsing errors.