Building CLI Tools with clap and structopt



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 around clap 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:

  1. Add tasks: Add a new task to the todo list.
  2. List tasks: Show all tasks.
  3. 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?

  1. StructOpt Derive Macro: The #[derive(StructOpt)] macro automatically parses command-line arguments into Rust structs.
  2. Enum for Subcommands: Each subcommand (Add, List, Complete) is represented as a variant of the Command enum.
  3. Declarative Syntax: Command-line arguments like description and id 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

  1. structopt Simplifies CLI Building: Its declarative syntax and integration with clap make it the go-to choice for most Rust CLI applications.
  2. Persistence Adds Value: Storing data (e.g., tasks) makes your CLI much more practical and usable.
  3. Modularize for Scalability: As your CLI grows, refactor code into smaller modules for maintainability.

What’s next?

  1. Dive deeper into clap for more advanced features like custom validators and dynamic completions.
  2. Explore async Rust to handle network-based or long-running tasks in your CLI.
  3. 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