DEV Community

Cover image for Rust cli example #2: Ferris hunts errors
René Ribaud
René Ribaud

Posted on

Rust cli example #2: Ferris hunts errors

Table of Contents

Why this article

This article is

second part.

Before starting, I would like to thank everybody who read the previous article. The statistics were encouraging for a first article and fueled my motivation to continue writing new posts.

The article was also mentioned in This week in rust newsletter. So a big thank you to the authors. I can only highly recommend this newsletter as collecting all information about the rust ecosystem every week is a fantastic job.

Status and goals

In the previous post, we ended with a working CLI. However, the error management was not well implemented. In most cases, we want to bubble up the errors and deal with them in the main program function.

So this is what we will do now. Then we will improve the code a bit, separate it into files and add a logger.

Use our errors

We will focus on the get_gopher() function and explain the changes step by step.
The compiler will be our assistant and will help us to resolve errors.

fn get_gopher(gopher: String) {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("https://github.com/scraly/gophers/raw/main/{}.png", gopher);
    let response = minreq::get(url)
        .send()
        .expect("Fail to get response from server");

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name).expect("Fail to create file");
        output_file
            .write_all(response.as_bytes())
            .expect("Fail to write file");
        println!("Perfect! Just saved in {}", &file_name);
    } else {
        eprintln!("Gopher {} not exists", gopher);
    }
}
Enter fullscreen mode Exit fullscreen mode

Our first goal will be to refactor the code and change the function signature to return a Result.

fn get_gopher(gopher: String) -> Result<String, Error> {
...
}
Enter fullscreen mode Exit fullscreen mode

Of course, at this point, the compiler will yell because the type Error is not defined.

cannot find type `Error` in this scope: not found in this scope
Enter fullscreen mode Exit fullscreen mode

So we need to define it with an enum.

enum Error {
    GopherNotFound(String),
}
Enter fullscreen mode Exit fullscreen mode

Now the compiler will yell again because our function does not return a Result.

error[E0308]: mismatched types
  --> src/main.rs:35:36
   |
35 |       if response.status_code == 200 {
   |  ____________________________________^
36 | |         let file_name = format!("{}.png", gopher);
37 | |         let mut output_file = File::create(&file_name).expect("Fail to create file");
38 | |         output_file
...  |
41 | |         println!("Perfect! Just saved in {}", &file_name);
42 | |     } else {
   | |_____^ expected enum `Result`, found `()`
   |
   = note:   expected enum `Result<String, Error>`
           found unit type `()`
...
Enter fullscreen mode Exit fullscreen mode

So let's do it.
In the first branch of the if, we implicitly (no semicolon at the end of the line) return a string

Ok(format!("Perfect! Just saved in {}", &file_name))
Enter fullscreen mode Exit fullscreen mode

and in the other, we return an Error::GopherNotFound with a description string.

Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
Enter fullscreen mode Exit fullscreen mode

So we ended up with the following function.

fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("https://github.com/scraly/gophers/raw/main/{}.png", gopher);
    let response = minreq::get(url)
        .send()
        .expect("Fail to get response from server");

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name).expect("Fail to create file");
        output_file
            .write_all(response.as_bytes())
            .expect("Fail to write file");
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

The function looks ok, but the compiler is still not happy.

error[E0308]: `match` arms have incompatible types
  --> src/main.rs:55:13
   |
52 | /     match cmd {
53 | |         Command::Get { gopher } => get_gopher(gopher),
   | |                                    ------------------ this is found to be of type `Result<String, Error>`
54 | |         Command::Completion { shell } => {
55 | |             Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
   | |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected enum `Result`, found `()`
56 | |         }
57 | |     }
   | |_____- `match` arms have incompatible types
   |
   = note:   expected type `Result<String, Error>`
           found unit type `()`
...
Enter fullscreen mode Exit fullscreen mode

As we change the definition of our function to return a Result, we also need to change the call in the main function and manage the Result.
This can be simply done using pattern matching around the get_gopher() function.

fn main() {
    let cmd = Command::from_args();
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => println!("{}", msg),
            Err(Error::GopherNotFound(msg)) => eprintln!("{}", msg),
        },
        Command::Completion { shell } => {
            Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And now, the program can compile without errors or warnings.
So we have defined our error if our gopher is not available. But the get_gother() function can still fail and panic. As an example, if the file cannot be created. In fact, we need to remove all the expect() methods that could make our code panic in the get_gopher() function.
This can be done using the question mark operator.
Let's refactor our code and remove the first expect() of the function.

So we change the following code from:

    let response = minreq::get(url)
        .send()
        .expect("Fail to get response from server");

Enter fullscreen mode Exit fullscreen mode

to:

    let response = minreq::get(url).send()?;
Enter fullscreen mode Exit fullscreen mode

Unfortunately, the compiler is not happy again.

error[E0277]: `?` couldn't convert the error to `Error`
  --> src/main.rs:31:43
   |
28 | fn get_gopher(gopher: String) -> Result<String, Error> {
   |                                  --------------------- expected `Error` because of this
...
31 |     let response = minreq::get(url).send()?;
   |                                           ^ the trait `From<minreq::Error>` is not implemented for `Error`
   |
   = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait
   = note: required because of the requirements on the impl of `FromResidual<Result<Infallible, minreq::Error>>` for `Result<String, Error>`
   = note: required by `from_residual`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0277`.
Enter fullscreen mode Exit fullscreen mode

However, it gives us what is wrong and explains how to fix the issue. If the send() method fails, it returns a minreq::Error, and our function expects an Error. So we need a conversion. It can be achieved by implementing the From trait.
So let's do that. But, first, we need to add this new kind of error (Response) in our Error enum.

enum Error {
    GopherNotFound(String),
    Response(String),
}
Enter fullscreen mode Exit fullscreen mode

And now we implement the conversion with the From trait:

impl From<minreq::Error> for Error {
    fn from(err: minreq::Error) -> Self {
        Error::Response(err.to_string())
    }
}
Enter fullscreen mode Exit fullscreen mode
error[E0004]: non-exhaustive patterns: `Err(Response(_))` not covered
   --> src/main.rs:58:42
    |
58  |         Command::Get { gopher } => match get_gopher(gopher) {
    |                                          ^^^^^^^^^^^^^^^^^^ pattern `Err(Response(_))` not covered
    | 
   ::: /home/uggla/rust/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/result.rs:250:5
    |
250 |     Err(#[stable(feature = "rust1", since = "1.0.0")] E),
    |     --- not covered
    |
    = help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
    = note: the matched value is of type `Result<String, Error>`
Enter fullscreen mode Exit fullscreen mode

Rust is stringent, and the pattern matching needs to cover all cases. As we have just introduced a new case (Error::Response), we also need to modify the main function in such way.

fn main() {
    let cmd = Command::from_args();
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => println!("{}", msg),
            Err(Error::GopherNotFound(msg)) => eprintln!("{}", msg),
            Err(Error::Response(msg)) => eprintln!("{}", msg),
        },
        Command::Completion { shell } => {
            Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the compiler is happy, and we don't have errors or warnings anymore.
We can now get rid of the next expect() and proceed exactly like we just have done before.

fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("https://github.com/scraly/gophers/raw/main/{}.png", gopher);
    let response = minreq::get(url).send()?;

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name)?;
        output_file
            .write_all(response.as_bytes())
            .expect("Fail to write file");
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we have the following error as the std::io::Error type needs to be converted to our Error type.

error[E0277]: `?` couldn't convert the error to `Error`
  --> src/main.rs:42:55
   |
35 | fn get_gopher(gopher: String) -> Result<String, Error> {
   |                                  --------------------- expected `Error` because of this
...
42 |         let mut output_file = File::create(&file_name)?;
   |                                                       ^ the trait `From<std::io::Error>` is not implemented for `Error`
   |
...
Enter fullscreen mode Exit fullscreen mode

Add a new kind of error (IO).

enum Error {
    GopherNotFound(String),
    Response(String),
    IO(String),
}
Enter fullscreen mode Exit fullscreen mode

Implement the conversion.

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::IO(err.to_string())
    }
}
Enter fullscreen mode Exit fullscreen mode

Add the case in main()

fn main() {
    let cmd = Command::from_args();
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => println!("{}", msg),
            Err(Error::GopherNotFound(msg)) => eprintln!("{}", msg),
            Err(Error::Response(msg)) => eprintln!("{}", msg),
            Err(Error::IO(msg)) => eprintln!("{}", msg),
        },
        Command::Completion { shell } => {
            Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Looks good, remove the latest expect().

fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("https://github.com/scraly/gophers/raw/main/{}.png", gopher);
    let response = minreq::get(url).send()?;

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name)?;
        output_file.write_all(response.as_bytes())?;
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

As write_all() method returns a std::io::Error in case of failure, we already implemented this conversion. There is nothing more to do.

Small refactoring to improve code

Specify the BASE_URL

It will be more convenient to specify the base URL to retrieve the gophers.
We can simply define a const at the beginning of our program.

const BASE_URL: &str = "https://github.com/scraly/gophers/raw/main";
Enter fullscreen mode Exit fullscreen mode

And craft the URL variable using the BASE_URL const.

fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("{}/{}.png", BASE_URL, gopher);
    ...
Enter fullscreen mode Exit fullscreen mode

Factorize errors and send an errorlevel to the OS

Create the function

fn display_error_and_exit(error_msg: String) {
    eprintln!("{}", error_msg);
    exit(255)
}
Enter fullscreen mode Exit fullscreen mode

We need to define the exit function.

use std::process::exit;
Enter fullscreen mode Exit fullscreen mode

Call the display_error_and_exit() function from main.

fn main() {
    let cmd = Command::from_args();
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => println!("{}", msg),
            Err(Error::GopherNotFound(msg)) => display_error_and_exit(msg),
            Err(Error::Response(msg)) => display_error_and_exit(msg),
            Err(Error::IO(msg)) => display_error_and_exit(msg),
        },
...
Enter fullscreen mode Exit fullscreen mode

Create a gopher module to separate responsibility into files.

The idea is to move the get_gopher() function into a module.
The benefits will be to:

  • Reduce the size of main.
  • Better separate things.
  • Module will improve code reusability.

First, we need to create a gopher.rs file in our src directory. Then we move the error definitions and the get_gopher() function.

const BASE_URL: &str = "https://github.com/scraly/gophers/raw/main";

enum Error {
    GopherNotFound(String),
    Response(String),
    IO(String),
}

impl From<minreq::Error> for Error {
    fn from(err: minreq::Error) -> Self {
        Error::Response(err.to_string())
    }
}

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::IO(err.to_string())
    }
}

fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("{}/{}.png", BASE_URL, gopher);
    let response = minreq::get(url).send()?;

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name)?;
        output_file.write_all(response.as_bytes())?;
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

Of course, at this point, the compiler is becoming mad because it can not find the get_gopher() function and errors definitions.

cannot find function `get_gopher` in this scope: not found in this scope
Enter fullscreen mode Exit fullscreen mode

We need to tell it that it is now in a new module/file.
So let's do it in our main file.

mod gopher;
Enter fullscreen mode Exit fullscreen mode

We also need to import the function from the gopher module.

use gopher::*;
Enter fullscreen mode Exit fullscreen mode

Unfortunately, this is still not working as the get_gopher() function is private. We need to change it to public as well as the enum declaration.

pub enum Error {
...
pub fn get_gopher(gopher: String) -> Result<String, Error> {
...
Enter fullscreen mode Exit fullscreen mode

After saving, most of the errors vanished. There are remaining ones about import not used.

unused import: `std::fs::File`
unused import: `std::io::Write`
Enter fullscreen mode Exit fullscreen mode

We simply need to move them in the gopher module.

use std::fs::File;
use std::io::Write;
Enter fullscreen mode Exit fullscreen mode

So we ended up with the following code:
main.rs:

mod gopher;
use gopher::*;

use std::io::stdout;
use std::process::exit;
use structopt::clap::{crate_name, crate_version, Shell};
use structopt::StructOpt;

#[derive(StructOpt, Debug)]
#[structopt(name = "rust-gopher-friend-cli", version = crate_version!(), about = "Gopher CLI application written in Rust.")]
enum Command {
    /// This command will get the desired Gopher
    Get {
        /// Gopher type
        #[structopt()]
        gopher: String,
    },
    /// Generate completion script
    Completion {
        /// Shell type
        #[structopt(possible_values = &["bash", "fish", "zsh", "powershell", "elvish"])]
        shell: Shell,
    },
}

fn display_error_and_exit(error_msg: String) {
    eprintln!("{}", error_msg);
    exit(255)
}

fn main() {
    let cmd = Command::from_args();
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => println!("{}", msg),
            Err(Error::GopherNotFound(msg)) => display_error_and_exit(msg),
            Err(Error::Response(msg)) => display_error_and_exit(msg),
            Err(Error::IO(msg)) => display_error_and_exit(msg),
        },
        Command::Completion { shell } => {
            Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

gopher.rs:

use std::fs::File;
use std::io::Write;

const BASE_URL: &str = "https://github.com/scraly/gophers/raw/main";

pub enum Error {
    GopherNotFound(String),
    Response(String),
    IO(String),
}

impl From<minreq::Error> for Error {
    fn from(err: minreq::Error) -> Self {
        Error::Response(err.to_string())
    }
}

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::IO(err.to_string())
    }
}

pub fn get_gopher(gopher: String) -> Result<String, Error> {
    println!("Try to get {} Gopher...", gopher);
    let url = format!("{}/{}.png", BASE_URL, gopher);
    let response = minreq::get(url).send()?;

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name)?;
        output_file.write_all(response.as_bytes())?;
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} not exists",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

Add a simple logger

The idea here is to remove all println!() and eprintln!() macros and use a simple logger to give information to the user.

First, we need to add the required dependencies to our Cargo.toml.
We will use the log crate as a frontend log facility. Here is an extract of the documentation to understand the purpose of this crate.

A logging facade provides a single logging API that abstracts over the actual logging implementation. Libraries can use the logging API provided by this crate, and the consumer of those libraries can choose the logging implementation that is most suitable for its use case.
Sources

As a backend facility, we will use the simple_logger crate. This crate will simply output messages formatted like this 2015-02-24 01:05:20 WARN [logging_example] This is an example message.
I like the crate because it is simple to use and a good fit for small projects. Also, I contributed to another project (rust-riemann-client) maintained by the same author @borntyping (hello Sam) and, it was really an excellent experience.
Sources

[dependencies]
minreq = { version = "2.4.2", features = ["https-rustls-probe"] }
structopt = "0.3.22"
log = "0.4.14"
simple_logger = "1.13.0"
Enter fullscreen mode Exit fullscreen mode

Now we just need to initialize our simple logger.

We import it.

use simple_logger::SimpleLogger;
Enter fullscreen mode Exit fullscreen mode

And initialize it at the beginning of main with the default level set to info.

fn main() {
    SimpleLogger::new()
        .with_level(log::LevelFilter::Info)
        .init()
        .unwrap();
...
Enter fullscreen mode Exit fullscreen mode

Now we have just to replace println! and eprintln! macros with the respective ones log::info! and log::error!.
main.rs:

mod gopher;

use gopher::*;
use simple_logger::SimpleLogger;
use std::io::stdout;
use std::process::exit;
use structopt::clap::{crate_name, crate_version, Shell};
use structopt::StructOpt;

#[derive(StructOpt, Debug)]
#[structopt(name = "rust-gopher-friend-cli", version = crate_version!(), about = "Gopher CLI application written in Rust.")]
enum Command {
    /// This command will get the desired Gopher
    Get {
        /// Gopher type
        #[structopt()]
        gopher: String,
    },
    /// Generate completion script
    Completion {
        /// Shell type
        #[structopt(possible_values = &["bash", "fish", "zsh", "powershell", "elvish"])]
        shell: Shell,
    },
}

fn display_error_and_exit(error_msg: String) {
    log::error!("{}", error_msg);
    exit(255)
}

fn main() {
    SimpleLogger::new()
        .with_level(log::LevelFilter::Info)
        .init()
        .unwrap();

    let cmd = Command::from_args();
    log::debug!("{:#?}", cmd);
    match cmd {
        Command::Get { gopher } => match get_gopher(gopher) {
            Ok(msg) => log::info!("{}", msg),
            Err(Error::GopherNotFound(msg)) => display_error_and_exit(msg),
            Err(Error::Response(msg)) => display_error_and_exit(msg),
            Err(Error::IO(msg)) => display_error_and_exit(msg),
        },
        Command::Completion { shell } => {
            Command::clap().gen_completions_to(crate_name!(), shell, &mut stdout())
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

gopher.rs:

use std::fs::File;
use std::io::Write;

const BASE_URL: &str = "https://github.com/scraly/gophers/raw/main";

pub enum Error {
    GopherNotFound(String),
    Response(String),
    IO(String),
}

impl From<minreq::Error> for Error {
    fn from(err: minreq::Error) -> Self {
        Error::Response(err.to_string())
    }
}

impl From<std::io::Error> for Error {
    fn from(err: std::io::Error) -> Self {
        Error::IO(err.to_string())
    }
}

pub fn get_gopher(gopher: String) -> Result<String, Error> {
    log::info!("Try to get {} Gopher...", gopher);
    let url = format!("{}/{}.png", BASE_URL, gopher);
    let response = minreq::get(url).send()?;

    if response.status_code == 200 {
        let file_name = format!("{}.png", gopher);
        let mut output_file = File::create(&file_name)?;
        output_file.write_all(response.as_bytes())?;
        Ok(format!("Perfect! Just saved in {}", &file_name))
    } else {
        Err(Error::GopherNotFound(format!(
            "Gopher {} does not exist",
            gopher
        )))
    }
}
Enter fullscreen mode Exit fullscreen mode

Run examples

Run ok

 cargo run -- get friends
   Compiling rust-gopher-friend-cli v0.1.0 (/home/uggla/workspace/rust/rust-gopher-friend-cli)
    Finished dev [unoptimized + debuginfo] target(s) in 1.56s
     Running `target/debug/rust-gopher-friend-cli get friends`
2021-09-08 01:00:36,294 INFO [rust_gopher_friend_cli::gopher] Try to get friends Gopher...
2021-09-08 01:00:39,169 INFO [rust_gopher_friend_cli] Perfect! Just saved in friends.png
Enter fullscreen mode Exit fullscreen mode

Run with error

 cargo run -- get friendsz
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/rust-gopher-friend-cli get friendsz`
2021-09-08 01:00:52,245 INFO [rust_gopher_friend_cli::gopher] Try to get friendsz Gopher...
2021-09-08 01:00:52,943 ERROR [rust_gopher_friend_cli] Gopher friendsz does not exist
Enter fullscreen mode Exit fullscreen mode

We reach the end of this article. Please let me know if you enjoy it in the comments or on Twitter.

All the code is available on my github account, and branches are used to describe the main steps.

See ya.

Top comments (0)