DEV Community

SomeB1oody
SomeB1oody

Posted on

[Rust Guide] 12.5. Refactoring Pt.3 - Moving Business Logic

12.5.0 Before We Begin

In Chapter 12, we will build a real project: a command-line program. This program is a grep (Global Regular Expression Print), a tool for global regular-expression search and output. Its job is to search for the specified text in the specified file.

This project has several steps:

  • Receive command-line arguments
  • Read files
  • Refactor: improve modules and error handling (this article)
  • Use TDD (test-driven development) to develop library functionality
  • Use environment variables
  • Write error messages to standard error instead of standard output

If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.

12.5.1 Review

The previous two articles completed modularization optimization and error handling. In this article, we will do further optimization on that basis.

Here is all the code written up to the previous article:

use std::env;  
use std::fs;  
use std::process;  

struct Config {  
    query: String,  
    filename: String,  
}  

fn main() {  
    let args:Vec<String> = env::args().collect();  
    let config = Config::new(&args).unwrap_or_else(|err| {  
        println!("Problem parsing arguments: {}", err);  
        process::exit(1);  
    });  

    let contents = fs::read_to_string(config.filename)  
        .expect("Something went wrong while reading the file");  
    println!("With text:\n{}", contents);  
}  

impl Config {  
    fn new(args: &[String]) -> Result<Config, &'static str> {  
        if args.len() < 3 {  
            return Err("Not enough arguments");  
        }  
        let query = args[1].clone();  
        let filename = args[2].clone();  
        Ok(Config { query, filename})  
    }  
}
Enter fullscreen mode Exit fullscreen mode

12.5.2 Extracting Logic from main

As discussed in 12.3. Refactoring Pt. 1, we follow the guiding principle for separating concerns in binary programs:

  • Split the program into main.rs and lib.rs, and put business logic in lib.rs
  • If the logic is small, keeping it in main.rs is fine
  • As the logic becomes more complex, extract it from main.rs into lib.rs

According to that principle, everything in main except configuration parsing and error handling should be extracted into a run function. That keeps main small enough that we can verify correctness just by reading it, while the rest of the logic can be verified through tests (see Chapter 11 for tests).

For the code we have so far, the run function should be:

fn run(config: Config) {  
    let contents = fs::read_to_string(config.filename)  
        .expect("Something went wrong while reading the file");  
    println!("With text:\n{}", contents);  
}
Enter fullscreen mode Exit fullscreen mode

main should also call run:

fn main() {  
    let args:Vec<String> = env::args().collect();  
    let config = Config::new(&args).unwrap_or_else(|err| {  
        println!("Problem parsing arguments: {}", err);  
        process::exit(1);  
    });  
    run(config);  
}
Enter fullscreen mode Exit fullscreen mode

12.5.3 Improving Error Handling in run

Right now, run uses expect for file-reading errors. That kind of error handling calls panic!. What we want instead is to propagate errors with Result, just like Config::new:

fn run(config: Config) -> Result<(), Box<dyn Error>> {  
    let contents = fs::read_to_string(config.filename)?;  
    println!("With text:\n{}", contents);  
    Ok(())  
}
Enter fullscreen mode Exit fullscreen mode
  • The Ok variant of Result corresponds to (), the unit type. This type means “nothing is returned” or “there is nothing here,” which is fine because run does not need to return anything on success. The final line Ok(()) returns Ok with a unit value.

  • The Err variant of Result is Box<dyn Error>. You do not need to understand it in depth yet; just know that it represents any type that implements the std::error::Error trait (here I wrote just Error because I imported it with use std::error::Error;). This means different error types can be returned in different situations. dyn is short for dynamic.

  • The ? symbol was explained in detail in 9.3. Result Enum and Recoverable Errors Pt. 2. Briefly, read_to_string returns a Result. Adding ? means that if read_to_string returns Ok, the value inside Ok is returned and assigned to the variable; if it returns Err, the function terminates immediately and returns the Err and its attached error information. In other words, ? is equivalent to:

let contents = match fs::read_to_string(config.filename){
    Ok(contents) => contents,
    Err(e) => return Err(e),
};
Enter fullscreen mode Exit fullscreen mode

With this change, the error is propagated to the caller, which is main, so main must handle the error:

fn main() {  
    let args:Vec<String> = env::args().collect();  
    let config = Config::new(&args).unwrap_or_else(|err| {  
        println!("Problem parsing arguments: {}", err);  
        process::exit(1);  
    });  
    if let Err(e) = run(config) {  
        println!("Application error: {}", e);  
        process::exit(1);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

The if let used here is syntactic sugar for match; think of it as a match that handles only one branch. See 6.4. Simple Control Flow - If Let for details. Note that if let and if are not the same thing, so do not confuse them.

12.5.4 Moving the Business Logic

Now that all the functions and error handling are separated, the next step is to move them into lib.rs.

The things to move are these functions, structs, and related imports.

The result after moving them (lib.rs) is:

use std::error::Error;  
use std::fs;  

pub struct Config {  
    pub query: String,  
    pub filename: String,  
}  

impl Config {  
    pub fn new(args: &[String]) -> Result<Config, &'static str> {  
        if args.len() < 3 {  
            return Err("Not enough arguments");  
        }  
        let query = args[1].clone();  
        let filename = args[2].clone();  
        Ok(Config { query, filename})  
    }  
}  

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {  
    let contents = fs::read_to_string(config.filename)?;  
    println!("With text:\n{}", contents);  
    Ok(())  
}
Enter fullscreen mode Exit fullscreen mode

Note: all structs, methods on structs, and functions used by main.rs must be marked pub so they can be called.

Now look at main.rs:

use std::env;  
use std::process;  
use minigrep::Config;  

fn main() {  
    let args:Vec<String> = env::args().collect();  
    let config = Config::new(&args).unwrap_or_else(|err| {  
        println!("Problem parsing arguments: {}", err);  
        process::exit(1);  
    });  
    if let Err(e) = minigrep::run(config) {  
        println!("Application error: {}", e);  
        process::exit(1);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

All refactoring tasks are complete. The next step is to write tests (the next article).

Top comments (0)