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 .
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)
}
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.
Your project structure should look like this:
Now, you can run the project with the following command:
cargo run -- test rap.txt
And this should be the output:
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,
}
...
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 }
}
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);
}
Let's run the project again:
cargo run -- test rap.txt
And this should be the output:
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 }
}
}
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);
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);
}
Let's run the project again:
cargo run -- test rap.txt
And this should be the output:
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
And we get a panic error:
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 })
}
}
And in this case I get at least an error message if I type cargo run
:
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 })
}
}
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);
}
...
Now, if we run the project without passing the arguments, we get a message:
cargo run -- test rap.txt
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(())
}
...
And we can call this function from the main
function:
...
run(config);
...
Let's make a quick test:
cargo run -- test rap.txt
And this should be the output:
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(())
}
...
I can see a warning in the main
function, because we are not handling the error returned by the run
function.
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);
}
}
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(())
}
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);
}
}
Now we can run the project again:
cargo run -- test rap.txt
And this should be the output:
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