Create the CLI module
The cli
module will handle user input, which users will enter through a command-line
interface (hence the name). To add the module to our project, we need to create a file named src/cli.rs
at
the project root and add the following line to the main.rs
file:
mod cli;
The new module doesn't contain any code. Let's change that.
Handle command-line parameters by using structopt
You could parse and handle command-line arguments by using Rust's standard library. But it would
require a tremendous amount of code and effort to do that reasonably well. We'll use a third-party crate called structopt
that will make this task as easy as defining a simple struct.
By running the command cargo search structopt
, you can check whether it's available and determine the most
recent version:
$ cargo search structopt
structopt = "0.3.21" # Parse command-line argument by defining a struct.
...
Let's add it as a dependency for our project by adding the following entry to the [dependencies]
section of the Cargo.toml
file:
[dependencies]
structopt = "0.3"
From now on, we can refer to it directly from any part of our code.
Create the CommandLineArgs
struct
Next, we need to create a struct to represent all the possible actions our program can perform. In the previous unit, we defined those actions:
- Add a task.
- Remove a task.
- Print the task list.
If you thoughtfully read the structopt
README page, you might decide that the best way to express those alternating options is to use an enum
to hold all three actions.
Before we use structopt
, let's look at the types that will represent our command-line arguments:
use std::path::PathBuf;
pub enum Action {
Add { task: String },
Done { position: usize },
List,
}
pub struct CommandLineArgs {
pub action: Action,
pub journal_file: Option<PathBuf>,
}
The Action
enum has one variant for each kind of action we'll need in our program:
Action::Add
holds aString
that describes the task being added, like"buy milk"
or"take the dog on a walk"
.Action::Done
holds the number of a task that we'll mark as done. For example, a2
will cross out the second task in the numbered to-do list.Action::List
will print the task list in the terminal.
Next, our CommandLineArgs
struct holds the Action
enum as a wrapper. It also holds an optional
argument (note the Option
type) named journal_file
. This argument is for when a user wants to point to a journal
file that isn't the default one.
Wrapping the action
and the journal_file
types together allows us to apply the journal_file
optional argument to all nested subcommands declared in the Action
enum.
Derive StructOpt
Those types won't be of any use until we annotate them by using the structopt
attributes. The final
source code will look like this:
use std::path::PathBuf;
use structopt::StructOpt;
#[derive(Debug, StructOpt)]
pub enum Action {
/// Write tasks to the journal file.
Add {
/// The task description text.
#[structopt()]
task: String,
},
/// Remove an entry from the journal file by position.
Done {
#[structopt()]
position: usize,
},
/// List all tasks in the journal file.
List,
}
#[derive(Debug, StructOpt)]
#[structopt(
name = "Rusty Journal",
about = "A command line to-do app written in Rust"
)]
pub struct CommandLineArgs {
#[structopt(subcommand)]
pub action: Action,
/// Use a different journal file.
#[structopt(parse(from_os_str), short, long)]
pub journal_file: Option<PathBuf>,
}
In the final version of the cli.rs
file, we used #[derive(StructOpt)]
and several
#[structopt]
attributes to instruct Rust to generate a command-line argument parser by using our
CommandLineArgs
struct. The documentation strings (///
) are used to provide
descriptions for each aspect of the command-line interface.
Run the CLI program
It's time to take the program for a test drive. But first, modify the main.rs
source file to look like
this:
mod cli;
use structopt::StructOpt;
fn main() {
cli::CommandLineArgs::from_args();
}
When you use the cargo run
command, you'll be greeted by the Help message that structopt
generated from our CommandLineArgs
struct. Impressive, isn't it?
$ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/rusty-journal`
Rust Journal 0.1.0
A command line to-do app written in Rust
USAGE:
rusty-journal [OPTIONS] <SUBCOMMAND>
FLAGS:
-h, --help Prints help information
-V, --version Prints version information
OPTIONS:
-j, --journal-file <journal-file> Use a different journal file
SUBCOMMANDS:
add Write tasks to the journal file
done Remove an entry from the journal file by position
help Prints this message or the help of the given subcommand(s)
list List all tasks in the journal file
The program even produces errors when subcommands are called with the wrong arguments. Give it a try!
Use the parsed results
The point of using structopt
as the argument parser is that every valid invocation of the
command-line interface will produce a CommandLineArgs
value. We can use these values in the program to
invoke the specific behavior that the user wants.
Take a look at how some different uses of the app result in different values for the struct.
First, modify the main.rs
file to print the result of from_args()
. Then try to call the
program with different arguments.
mod cli;
use structopt::StructOpt;
fn main() {
println!("{:#?}", cli::CommandLineArgs::from_args());
}
Notice how each different invocation instantiates a different value for the struct.
// $ cargo run -- add "buy milk"
CommandLineArgs {
action: Add {
text: "buy milk",
},
journal_file: None,
}
// $ cargo run -- done 4
CommandLineArgs {
action: Done {
position: 4,
},
journal_file: None,
}
// $ cargo run -- -j groceries.txt list
CommandLineArgs {
action: List,
journal_file: Some(
"groceries.txt",
),
}
We can now use those values in the main.rs
file to guide program execution.
Next, let's look at the tasks
module file.