Rust Command line applications Walkthrough
- We can use
cargo new grrs
command to setup new project.
- A typical invocation of our code tool will look like this:
$ grrs sahil names.txt
- we expect our program to look in
names.txt
and print out the lines that containssahil
. - The text after the name of program is often called the
command-lien arguments
orcommand-line flags
(specially when they look like--this
).
-
The standard library contains the function
std::env::args()
that gives you an iterator of the given arguments. The first entry(at index0
) will be the name your program was called as (eggrrs
), the one that follow are what the user wrote afterwords. -
Getting the raw arguments this way is quit easy:
use std::env::args;
fn main() {
let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
println!("pattern: {:?}, path: {:?}", pattern, path);
}
- output :
sahilwep~$ cargo run -- some-pattern some-files
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/grrs some-pattern some-files`
pattern: "some-pattern", path: "some-files"
-
Instead of thinking about them as bunch of text, it often pays off to think to CLI arguments as a custom data type that represent the inputs to your program.
-
In our program
grrs
we have two arguments, first ispattern
and thenpath
. -
While getting input from user:
- The first argument is expected to be string.
- The second argument is expected to be path.
-
We can structure programs around the data they handle, so this way of looking at CLI arguments fits very well.
struct CLI {
pattern: String,
path: std::path::PathBuf,
}
- NOTE :
PathBuf
is like aString
but for the system path that work cross-platform.
struct CLI {
pattern: String,
path: std::path::PathBuf,
}
fn main() {
let pattern = std::env::args().nth(1).expect("no pattern given");
let path = std::env::args().nth(2).expect("no path given");
let args = CLI {
pattern: pattern,
path: std::path::PathBuf::from(path),
};
println!("pattern: {:?}, path: {:?}", args.pattern, args.path);
}
- This works, but not very convenient. We can't deal with the requirement to support
--pattern="foo"
or--pattern "foo"
or--help
.
-
We can use one of the many available library to parse our CLI arguments. One of the popular is
clap
. It has all the functionality including support ofsub-commands
, shell completions, and great help message. -
For importing
clap
we need to addclap = {version = "4.0", features = ["derive"]}
to the dependencies section of ourCargo.toml
file. -
Now, we can write
use clap::Parser
in our code, and#[derive(parser)]
right above ourstruct CLI
use clap::Parser;
// Search for a pattern in a file and display the lines that contains it.
struct CLI {
// pattern to look for
pattern: String,
// The path to the file to read.
path: std::path::PathBuf,
}
-
Note : There are a lot custom attributes you can add to field. For example, to say you want to use this field for the argument after
-o
or--output
, you'd add#[arg(short = 'o', long = "output")]
. clap Documentation -
Right below the
CLI
struct our template contains itsmain
function. WHen the program starts, it will call this function.
fn main(){
let args = Cli::parse();
println!("pattern: {:?}, path: {:?}", args.pattern, args.path);
}
-
This will try to parse the arguments into our
CLI
struct. -
But what if that fails? That's the beauty of this approach: Clap knows which field to expect, and what their expected format is. It can automatically generate a nice
--help
message, as well as give some great errors to suggest you pass--output
when you wrote--putput
. -
NOTE : The
parse
method is meant to be used in yourmain
function. When it fails, it will print out an error or help message and immediately exit the program. Don't user it in other places!. -
Our code will look something like this :
use clap::Parser;
// Search for a pattern in a file and display the lines that contains it.
#[derive(Parser)]
struct CLI {
// pattern to look for
pattern: String,
// The path to the file to read.
path: std::path::PathBuf,
}
fn main() {
let args = CLI::parse();
println!("pattern: {:?}, path: {:?}", args.pattern, args.path);
}
- Output :
sahilwep~$ cargo run -- pattern-some path-some
Compiling grrs v0.1.0 (/Users/sahilwep/Developer/Development/Rust/CLI_Rust/grrs)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/grrs pattern-some path-some`
pattern: "pattern-some", path: "path-some"
- We can take argument as an input, but we don't know how to open the file.
- We can open any file with :
let content = std::fs:::read_to_string(&args.path).expect("could not read the file");
-
NOTE :
.expect
method here is a shortcut function that will make the program exit immediately when the value (in this case the input file) could not be read. It's not very pretty, and the next chapter on Nicer error reporting we will look at how to improve this. -
Now, let's iterate over the lines and print each one that contains our pattern:
for line in content.lines() {
if line.contents(&args.pattern) {
println!("{}", line);
}
}
- Our code will look like this:
use clap::Parser;
// Search for a pattern in a file and display the lines that contains it.
#[derive(Parser)]
struct CLI {
// pattern to look for
pattern: String,
// The path to the file to read.
path: std::path::PathBuf,
}
fn main() {
let args = CLI::parse();
let content = std::fs::read_to_string(&args.path).expect("Could not read file");
for line in content.lines() {
if line.contains(&args.pattern) {
println!("{}", line);
}
}
}
- Output :
sahilwep~$ cargo run -- name file.txt
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Running `target/debug/grrs name file.txt`
sahilwep is my name
- We all can do nothing but accept the fact that errors will occur. And in contrast to many other language. It's very hard not to notice and deal with his reality when using Rust: As it doesn't have exceptions, all possible error states are often encoded in the return types of function.
- A function like
read_to_string
doesn't return a string. Instead, it returns aResult
that contains either aString
or an error of some type(in casestd::io::Error
). - How do you know which it is? Since
Result
is anenum
, you can usematch
to check which variant it is:
let result = std::fs::read_to_string("text.txt");
match result {
Ok(content) => {println!("File content: {}", content);}
Err(error) => {println!("Oh noes: {}", error);}
}
- Now, we were able to access the content of the file, but we can't really do anything with it after the
match
block. For this, we'll need to somehow deal with the error case. The challenge is that all arms of amatch
block need to return something of the same type. But there's a neat trick to get around that:
let result = std::fs::read_to_string("test.txt");
let content = match result {
Ok(content) => {content},
Err(error) => { panic!("can't deal with {}, just exit here", error);}
};
println!("file content: {}", content);
-
We can use the String in
content
after the match block. Ifresult
were an error, the String wouldn't exit. But since the program would exit before it ever reached a point where we usecontent
, it's fine. -
This may seem drastic, but it's very convenient. If your program needs to read that file and can't do anything if the file doesn't exist, existing is valid strategy. There's even a shortcut method on
Result
s, calledunwrap
:
let content = std::fs::read_to_string("test.txt").unwrap();