DEV Community

Hamza Jadid
Hamza Jadid

Posted on

Building a CLI tool in Rust

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
Enter fullscreen mode Exit fullscreen mode

Project setup

Create a new bin rust project.

cargo new --bin dugh
cd dugh
Enter fullscreen mode Exit fullscreen mode

Add the dependencies that we need.

cargo add anyhow
cargo add clap -F derive
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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<()>;
}
Enter fullscreen mode Exit fullscreen mode

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(())
}
Enter fullscreen mode Exit fullscreen mode

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(()),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Okay, we have a working base now!

cargo run -- --help
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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(())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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(),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Let us see the result!

cargo run -- pr create
Enter fullscreen mode Exit fullscreen mode

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(())
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run again 👀

cargo run -- pr create --title fix/minor-bug-fix -d
Enter fullscreen mode Exit fullscreen mode

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 --"
Enter fullscreen mode Exit fullscreen mode

Now we can run it easier

cargo dugh -d -t "Hello World"
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
pxlmastrxd profile image
Pxlmastr

Great article! I enjoyed reading it. 😃