This content originally appeared on DEV Community and was authored by Gregory Chris
Building CLI Tools with clap
and structopt
: A Rust Guide to User-Friendly Command-Line Apps
Command-line interfaces (CLIs) are the unsung heroes of software development. Whether you’re automating tasks, managing servers, or tinkering with developer tools, a good CLI can make the difference between frustration and delight. Rust, with its focus on performance and safety, is an excellent choice for building fast, reliable, and user-friendly CLI tools.
In this blog post, we’ll dive into two popular argument parsing crates, clap
and structopt
, and learn how to build robust command-line applications in Rust. By the end, we’ll create a simple yet functional Todo CLI with arguments and subcommands. Along the way, we’ll cover best practices, common pitfalls, and actionable tips to level up your CLI-building skills.
Why clap
and structopt
?
Rust has a rich ecosystem of tools for building CLIs, but clap
and structopt
stand out for their ease of use, flexibility, and feature-rich design.
-
clap
: A powerful crate for parsing command-line arguments. It provides extensive customization options, subcommand support, and even automatic help and error messages. It’s widely adopted and actively maintained. -
structopt
: A wrapper aroundclap
that simplifies argument parsing by leveraging Rust’s type system and attributes. If you’re a fan of declarative programming,structopt
is your friend.
While structopt
is convenient for most use cases, it’s worth noting that clap
offers finer control for advanced scenarios. The good news? Starting with structopt
doesn’t lock you out of clap
; they integrate seamlessly.
Building a Todo CLI: The Plan
To make this tutorial practical, we’ll build a Todo CLI with the following features:
- Add tasks: Add a new task to the todo list.
- List tasks: Show all tasks.
- Complete a task: Mark a task as done.
We’ll use subcommands (add
, list
, and complete
) to organize functionality and arguments for user input.
Getting Started: Setting Up Your Rust Project
Start by creating a new Rust project:
cargo new todo_cli
cd todo_cli
Add structopt
to your Cargo.toml
file:
[dependencies]
structopt = "0.3"
Alternatively, if you prefer clap
, you can use:
[dependencies]
clap = "4.0" # Check crates.io for the latest version
We’ll use structopt
for simplicity in this tutorial.
Step 1: Defining Command-Line Arguments with structopt
Let’s begin by defining our CLI structure using structopt
. Create a file named main.rs
and write the following code:
use structopt::StructOpt;
/// Todo CLI: Manage your tasks efficiently
#[derive(StructOpt)]
struct TodoCli {
/// Subcommands to execute
#[structopt(subcommand)]
command: Command,
}
#[derive(StructOpt)]
enum Command {
/// Add a new task
Add {
/// Description of the task
description: String,
},
/// List all tasks
List,
/// Mark a task as complete
Complete {
/// ID of the task to complete
id: usize,
},
}
fn main() {
let args = TodoCli::from_args();
match args.command {
Command::Add { description } => {
println!("Adding task: {}", description);
// Logic to add the task goes here
}
Command::List => {
println!("Listing tasks...");
// Logic to list tasks goes here
}
Command::Complete { id } => {
println!("Completing task with ID: {}", id);
// Logic to complete the task goes here
}
}
}
What’s Happening Here?
-
StructOpt
Derive Macro: The#[derive(StructOpt)]
macro automatically parses command-line arguments into Rust structs. -
Enum for Subcommands: Each subcommand (
Add
,List
,Complete
) is represented as a variant of theCommand
enum. -
Declarative Syntax: Command-line arguments like
description
andid
are defined as struct fields, making the code easy to read and maintain.
Run the CLI to test its functionality:
cargo run -- add "Learn Rust"
cargo run -- list
cargo run -- complete 1
Step 2: Adding Persistent Storage
CLIs are more useful when they can persist data. Let’s store tasks in a file using serde
for serialization.
Add the following dependencies to your Cargo.toml
:
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
Update your code to include persistent storage:
use serde::{Deserialize, Serialize};
use structopt::StructOpt;
use std::fs;
use std::path::Path;
#[derive(StructOpt)]
struct TodoCli {
#[structopt(subcommand)]
command: Command,
}
#[derive(StructOpt)]
enum Command {
Add { description: String },
List,
Complete { id: usize },
}
#[derive(Serialize, Deserialize, Debug)]
struct Task {
id: usize,
description: String,
completed: bool,
}
const FILE_PATH: &str = "tasks.json";
fn main() {
let args = TodoCli::from_args();
match args.command {
Command::Add { description } => add_task(description),
Command::List => list_tasks(),
Command::Complete { id } => complete_task(id),
}
}
fn load_tasks() -> Vec<Task> {
if Path::new(FILE_PATH).exists() {
let data = fs::read_to_string(FILE_PATH).expect("Failed to read tasks");
serde_json::from_str(&data).expect("Failed to parse tasks")
} else {
vec![]
}
}
fn save_tasks(tasks: &[Task]) {
let data = serde_json::to_string(tasks).expect("Failed to serialize tasks");
fs::write(FILE_PATH, data).expect("Failed to write tasks");
}
fn add_task(description: String) {
let mut tasks = load_tasks();
let id = tasks.len() + 1;
tasks.push(Task {
id,
description,
completed: false,
});
save_tasks(&tasks);
println!("Task added: {}", description);
}
fn list_tasks() {
let tasks = load_tasks();
for task in &tasks {
println!(
"[{}] {} - {}",
task.id,
task.description,
if task.completed { "Completed" } else { "Pending" }
);
}
}
fn complete_task(id: usize) {
let mut tasks = load_tasks();
if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
task.completed = true;
save_tasks(&tasks);
println!("Task {} marked as completed!", id);
} else {
println!("Task {} not found!", id);
}
}
Step 3: Testing the Todo CLI
Run and test the CLI:
# Add tasks
cargo run -- add "Learn Rust"
cargo run -- add "Build a CLI"
# List tasks
cargo run -- list
# Complete a task
cargo run -- complete 1
# List tasks again
cargo run -- list
Common Pitfalls and How to Avoid Them
1. Error Handling
Rust is strict about errors, and working with files is error-prone. Always handle I/O errors gracefully using Result
and avoid panics in production code.
2. Missing Subcommands
Users might forget to include a subcommand. Provide clear error messages and default behaviors to guide them.
3. Bloated main.rs
Files
As your CLI grows, consider modularizing your code by splitting subcommand logic into separate modules or files.
Key Takeaways and Next Steps
-
structopt
Simplifies CLI Building: Its declarative syntax and integration withclap
make it the go-to choice for most Rust CLI applications. - Persistence Adds Value: Storing data (e.g., tasks) makes your CLI much more practical and usable.
- Modularize for Scalability: As your CLI grows, refactor code into smaller modules for maintainability.
What’s next?
- Dive deeper into
clap
for more advanced features like custom validators and dynamic completions. - Explore async Rust to handle network-based or long-running tasks in your CLI.
- Build and publish your CLI to crates.io for others to use!
With Rust’s tooling and libraries like clap
and structopt
, building robust, user-friendly CLI tools has never been easier. Now, it’s your turn—what will you automate next? Let me know in the comments!
This content originally appeared on DEV Community and was authored by Gregory Chris