πŸ•ŠοΈπŸ‡΅πŸ‡Έ <river>Palestine</sea> πŸ‡΅πŸ‡ΈπŸ•ŠοΈ

Building a CLI tool in Rust

Posted on Apr 7, 2023

Today we’re going learn how to write a CLI tool in rust through creating a dummy implementation of Github’s CLI tool.

We’re calling it dugh (dummy Github πŸ€“).

Defining the functionality

Before we start writing code, we should define the functionality of our tool.

We will start with managing pull requests command and making the tool extensible for other commands.

An example command would be:

cargo dugh pr create -t "title" -d

Project setup

Create a new bin rust project.

cargo new --bin dugh
cd dugh

Add the dependencies that we need.

cargo add anyhow
cargo add clap -F derive

We are using anyhow for error handling and clap for parsing the command line arguments with the derive feature to fill command line args into a structure.

Basic setup

Setup clap inside main.rs by making a struct and derive Parser

// main.rs
use anyhow::Result;
use clap::Parser;

#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {}

fn main() -> Result<()> {
    let cli = Cli::parse();
    Ok(())
}

We are going to create the Execute trait. The purpose of it is to make all subcommands implement it.

Not that important but I like to see things fall under the same rules.

// execute.rs
use anyhow::Result;

pub trait Execute {
    fn execute(&self) -> Result<()>;
}

And surely add mod execute; in main.rs

// main.rs
mod execute;

use anyhow::Result;
use clap::Parser;

#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {}

fn main() -> Result<()> {
    let cli = Cli::parse();
    Ok(())
}

Now we can create Dugh and have dugh’s implement Execute.

// dugh.rs
use crate::execute::Execute;
use clap::Subcommand;

#[derive(Subcommand)]
pub enum Dugh {
    /// Manage pull requests
    Pr,
}

impl Execute for Dugh {
    fn execute(&self) -> anyhow::Result<()> {
        match self {
            Self::Pr => Ok(()),
        }
    }
}

Add mod dugh, import it, and add it to the Cli struct.

// main.rs
mod dugh;
mod execute;

use crate::dugh::Dugh;
use anyhow::Result;
use clap::Parser;
use execute::Execute;

#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Dugh,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    cli.command.execute()
}

Okay, we have a working base now!

cargo run -- --help

It’s alive!

Adding PR subcommand

Create pr.rs, add mod pr;

// main.rs
mod dugh;
mod execute;
mod pr;

use crate::dugh::Dugh;
use anyhow::Result;
use clap::Parser;
use execute::Execute;

#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
    #[command(subcommand)]
    command: Dugh,
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    cli.command.execute()
}

and add our pr variants

// pr.rs
use clap::Subcommand;

#[derive(Subcommand)]
pub enum Pr {
    /// Create a pull request
    Create,
    /// List pull requests in a repo
    List,
    /// Show status of relevant pull requests
    Status,
}

The /// is the command help message that will be displayed when passing --help to the tool.

Then implement Execute

// pr.rs
use crate::execute::Execute;
use clap::Subcommand;

#[derive(Subcommand)]
pub enum Pr {
    /// Create a pull request
    Create,
    /// List pull requests in a repo
    List,
    /// Show status of relevant pull requests
    Status,
}

impl Execute for Pr {
    fn execute(&self) -> anyhow::Result<()> {
        match self {
            Self::Create => {
                println!("PR Created!");
                Ok(())
            }
            Self::List => {
                println!("List of PRs");
                Ok(())
            }
            Self::Status => {
                println!("PR status");
                Ok(())
            }
        }
    }
}

And now we can wire Pr with Dugh

// dugh.rs
use crate::{execute::Execute, pr::Pr};
use clap::Subcommand;

#[derive(Subcommand)]
pub enum Dugh {
    /// Manage pull requests
    Pr {
        #[command(subcommand)]
        pr_commands: Pr,
    },
}

impl Execute for Dugh {
    fn execute(&self) -> anyhow::Result<()> {
        match self {
            Self::Pr { pr_commands } => pr_commands.execute(),
        }
    }
}

Let us see the result!

cargo run -- pr create

Adding arguments to a command

Our Pr variants do not hold any data. Let us change that by making one of the variants a struct

// pr.rs
use crate::execute::Execute;
use clap::Subcommand;

#[derive(Subcommand)]
pub enum Pr {
    /// Create a pull request
    Create {
        #[arg(short, long)]
        title: String,
        #[arg(short, long)]
        draft: bool,
    },
    /// List pull requests in a repo
    List,
    /// Show status of relevant pull requests
    Status,
}

impl Execute for Pr {
    fn execute(&self) -> anyhow::Result<()> {
        match self {
            Self::Create { title, draft } => {
                println!("PR {title} Created! isDraft: {draft}");
                Ok(())
            }
            Self::List => {
                println!("List of PRs");
                Ok(())
            }
            Self::Status => {
                println!("PR status");
                Ok(())
            }
        }
    }
}

Run again πŸ‘€

cargo run -- pr create --title fix/minor-bug-fix -d

Alias the command

I like to alias the commands to make them easier to use and smaller.

# .cargo/config.toml
[alias]
dugh = "run -rq --bin dugh --"

Now we can run it easier

cargo dugh -d -t "Hello World"

Alias becomes handy when working with a complex project, for example, the current project I am working on has cargo workspace and two CLI tools.

Conclusion

We have a working base, but adding more functionality requires adding enum variants on the proper nesting level.

To support issue management, we add a new variant to Dugh enum. To make a new Pr command, we add a new enum variant for that, and we write the logic in the branch of that variant.