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.
Top comments (1)
Great article! I enjoyed reading it. 😃