Refactoring commands

Alert all commands.

Star Wars: Episode V - The Empire Strikes Back (1980)

At the end of the previous chapter we had a working key/value store with key expiry. In the process we learned to create a TCP/IP server, to implement a binary protocol, and to split the system into asynchronous components to simplify memory management. No small feat!

In this chapter, we prepare the code for the CodeCrafters challenge extensions, where we plan to implement replication, transactions, and other interesting features of Redis.

In the current architecture, commands are provided by the storage, which is a legacy of the initial structure where the system was basically just the storage itself. Later, we implemented some layers on top of it, and now the main component is the server.

So far, this wasn't a problem, but in general commands should be provided by the server, because not all commands need to interact with the storage. This is clear from the implementation of Storage itself. We can see that the methods command_get and command_set are wrappers around the methods get and set respectively, while command_ping and command_echo don't need access to the data.

This wouldn't be a practical problem if it was limited to simple commands like PING and ECHO. In the next chapter, we will implement the command INFO, which returns the configuration of the server. If you think about it, it's clear that it would be rather convoluted (and silly) for the storage to retrieve information about the server that contains it, just to return such information to the server itself.

At the moment, the journey of a command is the following:

  • The client encodes the command and sends it to the server.
  • The command reaches the Connection Handler that wraps it with a Request which is sent to the server receiver.
  • The request is processed by Server::process_request. The command contained in the request is extracted and passed to Storage::process_command.
  • Storage::process_command routes the command to a specific method of Storage.

What we need to do is to change the last two steps, so that the whole process becomes:

  • The client encodes the command and sends it to the server.
  • The command reaches the Connection Handler that wraps it with a Request which is sent to the server receiver.
  • The request is processed by Server::process_request. The command contained in the request is extracted and routed to a specific method of Server.

However, this might lead to an extremely long implementation of the structure Server, as every command would be a method. Therefore, it is worth isolating commands into separate functions that receive the server as a parameter, but are implemented in separate files. What we will end up with is something similar to the following code.

src/server.rs
use crate::commands::{echo, get, ping, set};

...

pub async fn process_request(request: Request, server: &mut Server) {

    ...

    let command_name = command[0].to_lowercase();

    match command_name.as_str() {
        "echo" => {
            echo::command(server, &request, &command).await;
         }
        "get" => {
            get::command(server, &request, &command).await;
        }
        "ping" => {
            ping::command(server, &request, &command).await;
        }

        ...

    };
}

It is important to understand that while moving commands from the storage component to the server is a necessity, isolating commands into separate functions is purely a matter of style. There is no real difference between a method of a struct and an isolated function that receives that struct, so the crucial part of the change you need to focus on is the migration of commands out of the storage.

As you can see, each command receives the server, the request, and the command itself. The reasons are:

  • The server is passed to grant access to everything else in the core system. After the refactoring with actors the server is the central unit that manages all the resources.
  • The request is passed because it contains the response channel. In the future it might also contain more details about the client or the nature of the request.
  • The command contains the command name (used for routing), parameters and options.

Step 6.1 - Rust modules#

To make sure we are refactoring the system correctly we should start isolating the code of one of the commands and check that we can run it properly. We will not remove the commands from the storage component until we have all four commands PING, ECHO, GET, and SET implemented externally.

We can start creating the directory src/commands that will host the code of each command of our system. Rust modules require a file called mod.rs that will list all the files that are exported. So, the final structure is shown below.

Cargo.toml
src/
 + commands/
 |  + mod.rs
 |  + echo.rs
 |  + ping.rs
 |  + ...
 + main.rs
 + server.rs
 + server_result.rs
 + ...

Create the empty module file src/commands/mod.rs and include the submodule in src/main.rs

src/main.rs
 use server::{run_server, Server};
 use tokio::sync::mpsc;
 
 mod commands;

 mod connection;
 mod request;
 mod resp;

You should be able to run cargo build and cargo test without errors.

Step 6.2 - Isolate PING#

Now we can implement the command PING. The code will be taken from Storage::command_ping, but we will need to change it to match the new structure. The code we copied returned the core part of the response, which is a RESP data type. However, if you have a look at Server::process_request you will see that the output of Storage::process_command is transformed and returned with request.data.

The final version of the function is therefore

src/commands/ping.rs
use crate::request::Request;
use crate::resp::RESP;
use crate::server::Server;
use crate::server_result::ServerValue;

pub async fn command(_server: &Server, request: &Request, _command: &Vec<String>) {
    request
        .data(ServerValue::RESP(RESP::SimpleString("PONG".to_string())))
        .await;
}

Remember that the method Request::data (src/request.rs) accepts a ServerValue, transforms it into a ServerMessage, and sends it using the response sender channel.

We also need to add the file to the module.

src/commands/mod.rs
pub mod ping;

Running cargo build at this point will result in warnings related to unused variables and functions, but no errors.

At this point we can copy the tests test_command_ping and test_command_ping_uppercase from src/storage.rs to src/commands/ping.rs. To work with the new command function, however, they need some changes as well. The new tests are

src/commands/ping.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crate::server_result::ServerMessage;
    use tokio::sync::mpsc;

    #[tokio::test]
    // Test that the function command processes
    // a `PING` request (lowercase) and that it
    // responds with a PONG.
    async fn test_command_ping() {
        let cmd = vec![String::from("ping")];
        let server = Server::new();
        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Data(ServerValue::RESP(RESP::SimpleString(String::from("PONG"))))
        )
    }

    #[tokio::test]
    // Test that the function command processes
    // a `PING` request (uppercase) and that it
    // responds with a PONG.
    async fn test_command_ping_uppercase() {
        let cmd = vec![String::from("PING")];
        let server = Server::new();
        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Data(ServerValue::RESP(RESP::SimpleString(String::from("PONG"))))
        )
    }
}

Please note that at this point cargo build will still complain that the function command is unused. As we said, we are not importing and using commands in the server until we have isolated all of them.

Step 6.3 - Isolate ECHO#

The work we have done with PING can be easily repeated with ECHO. The command comes from Storage::command_echo, while the test is test_command_echo, both modified accordingly.

src/commands/echo.rs
use crate::request::Request;
use crate::resp::RESP;
use crate::server::Server;
use crate::server_result::ServerValue;

pub async fn command(_server: &Server, request: &Request, command: &Vec<String>) {
    request
        .data(ServerValue::RESP(RESP::BulkString(command[1].clone())))
        .await;
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::server_result::ServerMessage;
    use tokio::sync::mpsc;

    #[tokio::test]
    // Test that the function command processes
    // an `ECHO` request and that it
    // responds with a copy of the input.
    async fn test_command() {
        let cmd = vec![String::from("echo"), String::from("hey")];
        let server = Server::new();
        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Data(ServerValue::RESP(RESP::BulkString(String::from("hey"))))
        );
    }
}

Finally, we need to add it to the module

src/commands/mod.rs
pub mod echo;
pub mod ping;

Step 6.4 - Isolate GET#

The commands GET and SET are slightly more complicated than PING and ECHO, but ultimately we are once again relocating them from the storage component to the server, adjusting their code to the new structure that uses requests.

The original implementation of the function is

src/storage.rs
    // The command `GET` retrieves the value of the given key
    // and responds with a bulk string that contains it.
    fn command_get(&mut self, command: &Vec<String>) -> StorageResult<RESP> {
        // Check the command length. The command
        // requires at least 1 parameter.
        if command.len() != 2 {
            return Err(StorageError::CommandSyntaxError(command.join(" ")));
        }

        // Use the function get to retrieve the value of the given key.
        let output = self.get(command[1].clone());

        match output {
            // If the key corresponds to a value, return
            // it as a bulk string.
            Ok(Some(v)) => Ok(RESP::BulkString(v)),
            // If the key is not in the storage, return
            // a RESP null value.
            Ok(None) => Ok(RESP::Null),
            Err(_) => Err(StorageError::CommandInternalError(command.join(" "))),
        }
    }

Here we see that two variants of StorageError need to be migrated to ServerError. We should also add a variant to signal that the storage has not been initialised.

src/server_result.rs
#[derive(Debug, PartialEq)]
pub enum ServerError {
    CommandInternalError(String),
    CommandSyntaxError(String),
    IncorrectData,
    StorageNotInitialised,
}

impl fmt::Display for ServerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ServerError::CommandInternalError(string) => {
                write!(f, "Internal error while processing {}.", string)
            }

            ServerError::CommandSyntaxError(string) => {
                write!(f, "Syntax error while processing {}.", string)
            }

            ServerError::IncorrectData => {
                write!(f, "Data received from stream is incorrect.")
            }

            ServerError::StorageNotInitialised => {
                write!(f, "Storage has not been initialised.")
            }
        }
    }
}

The variant ServerError::StorageNotInitialised was already there, as it had been created previously for process_request. The new function that implements GET is

src/commands/get.rs
use crate::request::Request;
use crate::resp::RESP;
use crate::server::Server;
use crate::server_result::{ServerError, ServerValue};

pub async fn command(server: &mut Server, request: &Request, command: &Vec<String>) {
    // Extract the storage from the server.
    let storage = match server.storage.as_mut() {
        Some(storage) => storage,
        None => {
            request.error(ServerError::StorageNotInitialised).await;
            return;
        }
    };

    // Check that the command received a single argument.
    if command.len() != 2 {
        request
            .error(ServerError::CommandSyntaxError(command.join(" ")))
            .await;
        return;
    }

    // Get the value of the key from the storage.
    let output = storage.get(command[1].clone());

    match output {
        Ok(Some(v)) => request.data(ServerValue::RESP(RESP::BulkString(v))).await,
        Ok(None) => request.data(ServerValue::RESP(RESP::Null)).await,
        Err(_) => {
            request
                .error(ServerError::CommandInternalError(command.join(" ")))
                .await
        }
    };
}

We need to borrow the storage as mutable, as per the prototype of Storage::get. The reason is that get might change the internal expiry table. To do this we need to pass &mut Server to command and use server.storage.as_mut() to convert from &mut Option<Storage> to Option<&mut Storage>.

Mutability and references

Here, the function command that implements GET receives &mut Server, while the same function that implements PING or ECHO receives &Server. As we want to use them together as part of a routing process, this mismatch might look suspicious.

Make sure you understand that while Server and &Server are two different types, reference mutability is "just" an additional check that Rust wants you to add to the type to signal that you want to be able to mutate the value that you are referencing.

Therefore, the various implementations of command will be called with a mutable reference to Server but can decide to accept it as mutable or not, depending on their needs.

We need to first check that the storage is available. The original function didn't have to do it, being part of the storage itself. The rest of the function is a straightforward adaptation of the original code. The original function had only one test, and we can take the opportunity to increase the coverage.

src/commands/get.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crate::server_result::ServerMessage;
    use crate::set::SetArgs;
    use crate::storage::Storage;
    use tokio::sync::mpsc;

    #[tokio::test]
    // Test that the function command processes
    // a `GET` request and that it
    // responds with the value of the key.
    async fn test_command() {
        let mut storage = Storage::new();
        storage
            .set("key".to_string(), "value".to_string(), SetArgs::new())
            .unwrap();

        let mut server = Server::new();
        server.set_storage(storage);

        let cmd = vec![String::from("get"), String::from("key")];

        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Data(ServerValue::RESP(RESP::BulkString(String::from("value"))))
        );
    }

    #[tokio::test]
    // Test that the function command processes
    // a `GET` request and that it
    // returns the correct error when
    // the storage is not initialised.
    async fn test_storage_not_initialised() {
        let mut server = Server::new();

        let cmd = vec![String::from("get"), String::from("key")];

        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Error(ServerError::StorageNotInitialised)
        );
    }

    #[tokio::test]
    // Test that the function command processes
    // a `GET` request and that it
    // returns the correct error when
    // the key is not specified.
    async fn test_wrong_syntax_missing_key() {
        let storage = Storage::new();
        let mut server = Server::new();
        server.set_storage(storage);

        let cmd = vec![String::from("get")];

        let (connection_sender, mut connection_receiver) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: connection_sender,
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            connection_receiver.try_recv().unwrap(),
            ServerMessage::Error(ServerError::CommandSyntaxError("get".to_string()))
        );
    }
}

As before, we need to add the new file to the module.

src/commands/mod.rs
pub mod echo;
pub mod get;
pub mod ping;

The new tests highlight a missing feature: Storage::get and Storage::set need to be public functions now

src/storage.rs
impl Storage {

     // Implement the `set` operation for the storage.
    fn set(&mut self, key: String, value: String, args: SetArgs) -> StorageResult<String> {
    pub fn set(&mut self, key: String, value: String, args: SetArgs) -> StorageResult<String> {

        ...

    }

     // Implement the `get` operation for the storage.
    fn get(&mut self, key: String) -> StorageResult<Option<String>> {
    pub fn get(&mut self, key: String) -> StorageResult<Option<String>> {

        ...

    }
}

Step 6.5 - Isolate SET#

At this stage, isolating SET is trivial, as the overall shape of the code is extremely similar to GET. Once again, we need to tackle new and old error cases using requests instead of just returning an error result.

src/commands/set.rs
use crate::request::Request;
use crate::resp::RESP;
use crate::server::Server;
use crate::server_result::{ServerError, ServerValue};
use crate::set::parse_set_arguments;

pub async fn command(server: &mut Server, request: &Request, command: &Vec<String>) {
    // Extract the storage from the server.
    let storage = match server.storage.as_mut() {
        Some(storage) => storage,
        None => {
            request.error(ServerError::StorageNotInitialised).await;
            return;
        }
    };

    // Check that the command received at least 2 arguments.
    if command.len() < 3 {
        request
            .error(ServerError::CommandSyntaxError(command.join(" ")))
            .await;
        return;
    }

    // Extract the key, the value, and the arguments
    // supported by the command `SET`.
    let key = command[1].clone();
    let value = command[2].clone();
    let args = match parse_set_arguments(&command[3..].to_vec()) {
        Ok(args) => args,
        Err(_) => {
            request
                .error(ServerError::CommandSyntaxError(command.join(" ")))
                .await;
            return;
        }
    };

    // Set the value of the key in the storage.
    if let Err(_) = storage.set(key, value, args) {
        request
            .error(ServerError::CommandInternalError(command.join(" ")))
            .await;
        return;
    };

    request
        .data(ServerValue::RESP(RESP::SimpleString(String::from("OK"))))
        .await;
}

As we did for GET, we need to check that the storage has been initialised and that the command syntax is correct. For SET, the syntax is not just a matter of the number of arguments, as the command has specific options and flags that we implemented in src/set.rs.

The original function Storage::command_set had only one test, and it's worth increasing the coverage as we did for GET.

src/commands/set.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crate::server_result::ServerMessage;
    use crate::storage::Storage;
    use tokio::sync::mpsc;

    #[tokio::test]
    // Test that the function command processes
    // a `SET` request and that it
    // responds with the correct message.
    async fn test_command() {
        let storage = Storage::new();
        let mut server: Server = Server::new();
        server.set_storage(storage);

        let cmd = vec![
            String::from("set"),
            String::from("key"),
            String::from("value"),
        ];

        let (request_channel_tx, mut request_channel_rx) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: request_channel_tx.clone(),
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            request_channel_rx.try_recv().unwrap(),
            ServerMessage::Data(ServerValue::RESP(RESP::SimpleString(String::from("OK"))))
        );
    }

    #[tokio::test]
    // Test that the function command processes
    // a `SET` request and that it
    // returns the correct error when
    // the storage is not initialised.
    async fn test_storage_not_initialised() {
        let mut server: Server = Server::new();

        let cmd = vec![
            String::from("set"),
            String::from("key"),
            String::from("value"),
        ];

        let (request_channel_tx, mut request_channel_rx) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: request_channel_tx.clone(),
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            request_channel_rx.try_recv().unwrap(),
            ServerMessage::Error(ServerError::StorageNotInitialised)
        );
    }

    #[tokio::test]
    // Test that the function command processes
    // a `SET` request and that it
    // returns the correct error when
    // the value is not specified.
    async fn test_wrong_syntax_missing_key() {
        let storage = Storage::new();
        let mut server: Server = Server::new();
        server.set_storage(storage);

        let cmd = vec![String::from("set"), String::from("key")];

        let (request_channel_tx, mut request_channel_rx) = mpsc::channel::<ServerMessage>(32);

        let request = Request {
            value: RESP::Null,
            sender: request_channel_tx.clone(),
        };

        command(&mut server, &request, &cmd).await;

        assert_eq!(
            request_channel_rx.try_recv().unwrap(),
            ServerMessage::Error(ServerError::CommandSyntaxError("set key".to_string()))
        );
    }
}

As we did before, the last change is to add the new file to the module

src/commands/mod.rs
pub mod echo;
pub mod get;
pub mod ping;
pub mod set;

Step 6.6 - Replace original implementation#

It's time to replace the original implementation of the four commands with the new one following the strategy described at the beginning of the chapter. The first step is to change process_request

src/server.rs
use crate::commands::{echo, get, ping, set};
use crate::connection::ConnectionMessage;
use crate::request::Request;
use crate::server_result::{ServerError, ServerValue};
use crate::server_result::ServerError;
use crate::storage::Storage;
use crate::RESP;
use std::time::Duration;
use tokio::sync::mpsc;

...

// Process an incoming request and return a result.
pub async fn process_request(request: Request, server: &mut Server) {
    // Check if the request is expressed using
    // a RESP array and extract the elements.
    let elements = match &request.value {
        RESP::Array(v) => v,
        _ => {
            request.error(ServerError::IncorrectData).await;
            return;
        }
    };

    // The vector that contains all the commands we need to process.
    let mut command = Vec::new();

    // Check that each element of the array is a
    // bulk string, extract the content, and add it
    // to the vector.
    for elem in elements.iter() {
        match elem {
            RESP::BulkString(v) => command.push(v.clone()),
            _ => {
                request.error(ServerError::IncorrectData).await;
                return;
            }
        }
    }

    let storage = match server.storage.as_mut() {
        Some(storage) => storage,
        None => {
            request.error(ServerError::StorageNotInitialised).await;
            return;
        }
    };

    // Process the command contained in the request.
    let response = storage.process_command(&command);

    match response {
        Ok(v) => {
            request.data(ServerValue::RESP(v)).await;
        }
        Err(_e) => (),
    }
    // Extract the command name to route the request.
    let command_name = command[0].to_lowercase();

    // Process the request using the requested command.
    match command_name.as_str() {
        "echo" => {
            echo::command(server, &request, &command).await;
        }
        "get" => {
            get::command(server, &request, &command).await;
        }
        "ping" => {
            ping::command(server, &request, &command).await;
        }
        "set" => {
            set::command(server, &request, &command).await;
        }
        _ => {
            request
                .error(ServerError::CommandNotAvailable(command[0].clone()))
                .await;
        }
    }
}

The type ServerValue is however still needed in the tests, so we should import it there.

src/server.rs
#[cfg(test)]
mod tests {
    use super::*;
    use crate::server_result::ServerMessage;
    use crate::server_result::{ServerMessage, ServerValue};

    #[test]
    fn test_create_new() {
        let server: Server = Server::new();

        match server.storage {
            Some(_) => panic!(),
            None => (),
        };
    }

...

And to make this work we need to add the variant ServerError::CommandNotAvailable

src/server_result.rs
#[derive(Debug, PartialEq)]
pub enum ServerError {
    CommandInternalError(String),
    CommandNotAvailable(String),
    CommandSyntaxError(String),
    IncorrectData,
    StorageNotInitialised,
}

impl fmt::Display for ServerError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ServerError::CommandInternalError(string) => {
                write!(f, "Internal error while processing {}.", string)
            }

            ServerError::CommandNotAvailable(c) => {
                write!(f, "The requested command {} is not available.", c)
            }

            ServerError::CommandSyntaxError(string) => {
                write!(f, "Syntax error while processing {}.", string)
            }

            ServerError::IncorrectData => {
                write!(f, "Data received from stream is incorrect.")
            }

            ServerError::StorageNotInitialised => {
                write!(f, "Storage has not been initialised.")
            }
        }
    }
}

At this point all the automated tests for the basic implementation work. We should however get rid of the original implementation.

src/storage.rs
use crate::resp::RESP;
use crate::set::{parse_set_arguments, KeyExpiry, SetArgs};
use crate::storage_result::{StorageError, StorageResult};
use crate::set::{KeyExpiry, SetArgs};
use crate::storage_result::StorageResult;
use std::collections::HashMap;
use std::ops::Add;
use std::time::{Duration, SystemTime};

...

impl Storage {

    // Process an incoming command with its parameters.
    pub fn process_command(&mut self, command: &Vec<String>) -> StorageResult<RESP> {

    ...

    }

...

    // The command `PING` responds with a simple string
    // that contains the value `PONG`.
    fn command_ping(&self, _command: &Vec<String>) -> StorageResult<RESP> {
        Ok(RESP::SimpleString("PONG".to_string()))
    }

    // The command `ECHO` responds with a bulk string
    // that contains the same value passed to `ECHO`.
    fn command_echo(&self, command: &Vec<String>) -> StorageResult<RESP> {
        Ok(RESP::BulkString(command[1].clone()))
    }

...

    // The command `SET` stores the given key and value
    // pair and responds with `OK`.
    fn command_set(&mut self, command: &Vec<String>) -> StorageResult<RESP> {

    ...

    }

    // The command `GET` retrieves the value of the given key
    // and responds with a bulk string that contains it.
    fn command_get(&mut self, command: &Vec<String>) -> StorageResult<RESP> {

    ...

    }

mod tests {

    #[test]
    // Test that the storage provides the function
    // command_ping, and that its output is correct.
    // Check the command in lowercase format.
    fn test_command_ping() {

    ...

    }

    #[test]
    // Test that the storage provides the function
    // command_ping, and that its output is correct.
    // Check the command in uppercase format.
    fn test_command_ping_uppercase() {

    ...

    }

    #[test]
    // Test that the storage provides the function
    // command_echo and that its output is correct.
    fn test_command_echo() {

    ...

    }

...

    #[test]
    // Test that the storage provides the function
    // command_set and that its output is correct.
    fn test_process_command_set() {

    ...

    }

    #[test]
    // Test that the storage provides the function
    // command_get and that its output is correct.
    fn test_process_command_get() {

    ...

    }

The two variants StorageError::CommandNotAvailable(String) and StorageError::CommandInternalError(String) are not used any more and can be removed.

src/storage_result.rs
use std::fmt;

#[derive(Debug, PartialEq)]
pub enum StorageError {
    CommandNotAvailable(String),
    CommandSyntaxError(String),
    CommandInternalError(String),
}

impl fmt::Display for StorageError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            StorageError::CommandNotAvailable(c) => {
                write!(f, "The requested command {} is not available!", c)
            }
            StorageError::CommandSyntaxError(string) => {
                write!(f, "Syntax error while processing {}!", string)
            }
            StorageError::CommandInternalError(string) => {
                write!(f, "Internal error while processing {}!", string)
            }
        }
    }
}

pub type StorageResult<T> = Result<T, StorageError>;

CodeCrafters

Stage 7: Expiry

Since this was a mere refactoring, our code should still pass Stage 7 of the CodeCrafters challenge just like at the end of the previous chapter.