Build a CLI in Rust

Francesco Ciulla - Sep 10 - - Dev Community

Let's build a CLI project in Rust, from scratch.

We will handle the terminal inputs, read from a file, and return an output. We will also handle any errors that might occur.

We will do this with a step-by-step approach, so you can follow along and understand the process.

This lesson is based on this lesson from the Rust book

If you prefer a video version:

You can find the code on GitHub, link in video description

Step 1: Create a new project

First, let's create a new project with Cargo, step inside the project folder, and open it in your favorite code editor.



cargo new cli_project
cd cli_project
code .


Enter fullscreen mode Exit fullscreen mode

For this project, we will have no dependencies, so we can start coding right away.

Step 2: Handle the inputs

Open the main.rs file and replace the content with the following code:



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

fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let (query, file_path) = parse_config(&args);

    println!("Searching for {}", query);
    println!("In file {}", file_path);

    //read the file
    let contents = fs::read_to_string(file_path)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}

fn parse_config(args: &[String]) -> (&str, &str) {

    let query = &args[1];
    let file_path = &args[2];

    (query, file_path)
}


Enter fullscreen mode Exit fullscreen mode

You also need to create a file at the root level, called for example rap.txt and put some text in it.



Yo, JavaScript, you’re sloppy, a mess in disguise,
Weak types and bugs, yeah, I see through your lies,
I’m Rust, no mercy, I’m here to slay,
You’re slow on the block, I’m taking your place.

Concurrency beast, I don’t break, I don’t bend,
While your callbacks choke, I race to the end,
Memory leaks? Nah, I shut that down,
You’re the past, JS, I’m the new king crowned.


Enter fullscreen mode Exit fullscreen mode

Your project structure should look like this:

Build a CLI project in Rust

Now, you can run the project with the following command:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

And this should be the output:

Build a CLI project in Rust

Explanation:

  • We collected the arguments from the terminal and passed them to the parse_config function.
  • We read the file and printed its content.
  • We handled the error that might occur when reading the file with expect.

Improve the configuration use Struct

We can improve the configuration by using a struct to hold the configuration values.



...
struct Config {
    query: String,
    file_path: String,
}
...


Enter fullscreen mode Exit fullscreen mode

And then we can use this struct in the parse_config function. We also need to use the clone method to avoid borrowing issues.



fn parse_config(args: &[String]) -> Config {
    let query = args[1].clone();
    let file_path = args[2].clone();

    Config { query, file_path }
}


Enter fullscreen mode Exit fullscreen mode

We should also update the main function to use the Config struct.



fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let config = parse_config(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    //read the file
    let contents = fs::read_to_string(config.file_path)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}


Enter fullscreen mode Exit fullscreen mode

Let's run the project again:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

And this should be the output:

Build a CLI project in Rust

Step 3: Add a function to parse config

We can improve the project by using a Struct Method to parse the configuration, using the impl keyword.



impl Config {
    fn new(args: &[String]) -> Config {
        let query = args[1].clone();
        let file_path = args[2].clone();

        Config { query, file_path }
    }
}


Enter fullscreen mode Exit fullscreen mode

The function new is a constructor that creates a new instance of the Config struct, and it's very similar to the parse_config function.

The difference is that by doing so, this becomes a method of the Config struct, and we can call it like this:



let config = Config::new(&args);


Enter fullscreen mode Exit fullscreen mode

So this is how the main function looks like now:



fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let config = Config::new(&args);

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    //read the file
    let contents = fs::read_to_string(config.file_path)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}


Enter fullscreen mode Exit fullscreen mode

Let's run the project again:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

And this should be the output:

Build a CLI project in Rust

Step 4: Handle errors

Up until now, we have been using the cargo run -- test rap.txt command to run the project. But what if we forget to pass the arguments?

Let's see what happens if we run the project without passing the arguments:



cargo run


Enter fullscreen mode Exit fullscreen mode

And we get a panic error:

Build a CLI project in Rust

This is not ideal, as we would like to handle this error gracefully, and maybe return a message to the user.

Something we can do, for example, is to improve the new method, by checking how many arguments were passed, and returning an error if the number of arguments is not correct.

We need 3 arguments: the program name, the query, and the file path.



impl Config {
    fn new(args: &[String]) -> Config {
        if args.len() < 3 {
            panic!("Not enough arguments");
        }

        let query = args[1].clone();
        let file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}


Enter fullscreen mode Exit fullscreen mode

And in this case I get at least an error message if I type cargo run:

Build a CLI project in Rust

We can improve it, by returning a Result type, and handling the error in the main function. Let's rename the new method to build and return a Result type.



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 file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}


Enter fullscreen mode Exit fullscreen mode

And in the main function, we can handle the error



...
use::std::process;

fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    //read the file
    let contents = fs::read_to_string(config.file_path)
        .expect("Something went wrong reading the file");

    println!("With text:\n{}", contents);
}
...


Enter fullscreen mode Exit fullscreen mode

Now, if we run the project without passing the arguments, we get a message:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

Build a CLI project in Rust

Running the logic in a separate function

We can also move the logic of reading the file to a separate function, to keep the main function clean.



...
fn run(config: Config) {

    //read the file
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{}", contents);

    Ok(())
}
...


Enter fullscreen mode Exit fullscreen mode

And we can call this function from the main function:



...
run(config);
...


Enter fullscreen mode Exit fullscreen mode

Let's make a quick test:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

And this should be the output:

Build a CLI project in Rust

Handle Errors in the run function

We can also handle the errors that might occur when reading the file in the run function.



...
use::std::error::Error;
...

fn run(config: Config) -> Result<(), Box<dyn Error>> {

    //read the file
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{}", contents);

    Ok(())
}
...


Enter fullscreen mode Exit fullscreen mode

I can see a warning in the main function, because we are not handling the error returned by the run function.

Build a CLI project in Rust

The reason is that the run function returns a Result type, and we should handle the error.

So let's do it:




fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = run(config) {
        println!("Application error: {}", e);
        process::exit(1);
    }
}


Enter fullscreen mode Exit fullscreen mode

Now we are handling the error returned by the run function.

Handle the logic in a separate module

We are almost done, but we can still improve the project by moving the logic to a separate module.

Let's create a new file called lib.rs in the src folder, and move the Config struct and the run function to this file.



pub struct Config {
    pub query: String,
    pub file_path: 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 file_path = args[2].clone();

        Ok(Config { query, file_path })
    }
}

pub fn run(config: Config) -> Result<(), Box<dyn Error>> {

    //read the file
    let contents = fs::read_to_string(config.file_path)?;

    println!("With text:\n{}", contents);

    Ok(())
}


Enter fullscreen mode Exit fullscreen mode

And in the main.rs file, we can import the Config struct and the run function from the lib module.



use std::{env, process};
use std::error::Error;

use cli_project::Config;

fn main() {
    //get the arguments in input
    let args: Vec<String> = env::args().collect();

    //parse the arguments
    let config = Config::new(&args).unwrap_or_else(|err| {
        println!("Problem parsing arguments: {}", err);
        process::exit(1);
    });

    println!("Searching for {}", config.query);
    println!("In file {}", config.file_path);

    if let Err(e) = cli_project::run(config) {
        println!("Application error: {}", e);
        process::exit(1);
    }
}


Enter fullscreen mode Exit fullscreen mode

Now we can run the project again:



cargo run -- test rap.txt


Enter fullscreen mode Exit fullscreen mode

And this should be the output:

Build a CLI project in Rust

Conclusion

We have built a CLI project in Rust, from scratch, handling the inputs from the terminal, reading from a file, and returning an output. We have also handled the errors that might occur.

We have done this with a step-by-step approach, so you can follow along and understand the process.

I hope you enjoyed this tutorial and learned something new. If you have any questions, feel free to ask in the comments.

If you prefer a video version:

You can find the code on GitHub, link in video description

You can find me here

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .