add post about building rust CLIs
ci/woodpecker/manual/woodpecker Pipeline was successful Details

This commit is contained in:
Kaan Barmore-Genç 2023-12-09 00:44:49 -06:00
parent bd9ccce5fb
commit 0bcc672bbe
Signed by: kaan
GPG Key ID: B2E280771CD62FCF
3 changed files with 182 additions and 2 deletions

View File

@ -8,7 +8,7 @@ steps:
build:
image: klakegg/hugo:ext-alpine
commands:
- hugo
- hugo
search:
image: seriousbug/pagefind
commands:

View File

@ -0,0 +1,180 @@
---
title: "Building a Well Organized Rust CLI Tool"
date: 2023-12-09T00:05:58-06:00
toc: false
images:
tags:
- dev
- rust
---
Here's a pattern came up with for building CLI tools in Rust using
[Clap](https://crates.io/crates/clap) with derive and using a custom trait.
First, define a "run command" trait that all commands will use.
```rs
#[async_trait::async_trait]
pub trait RunCommand {
async fn run(&self) -> anyhow::Result<()>;
}
```
I'm using [async-trait](https://crates.io/crates/async-trait) so my commands can
be async, and [anyhow](https://crates.io/crates/anyhow) for error handling
because I don't particularly care about the types of the errors my commands
might return. Neither of these are required though, skip them if you prefer.
The "trick" is to then implement your `RunCommand` trait for all the commands
and subcommands for your Clap parser. This is just how traits work, but what's
useful is that it makes it really easy to organize your commands and subcommands
into different folders while keeping the definition of the command next to the
implementation of that command.
For example, imagine a program with commands like:
```
myprogram query --some-params
myprogram account create --other-params
myprogram account delete --even-more-params
```
I'd recommend organizing the code into a folder structure like this:
```
.
├── account
│ ├── create.rs
│ ├── delete.rs
│ └── mod.rs
├── mod.rs
├── query.rs
└── run_command.rs <-- the RunCommand trait is defined here
```
These files will then look like this:
##### `account/create.rs`:
```rs
#[derive(Debug, Args)]
pub struct AccountCreate { /* params */ }
#[async_trait::async_trait]
impl RunCommand for AccountCreate {
async fn run(&self) -> anyhow::Result<()> {
// Your command implementation here!
}
}
```
##### `account/delete.rs`
Omitted for brevity, same as create
##### `account/mod.rs`:
```rs
#[derive(Debug, Subcommand)]
pub enum AccountCommands {
Create(AccountCreate),
Delete(AccountDelete),
}
#[async_trait]
impl RunCommand for AccountCommands {
async fn run(&self) -> anyhow::Result<()> {
match self {
Self::Create(cmd) => cmd.run().await,
Self::Delete(cmd) => cmd.run().await,
}
}
}
#[derive(Debug, Args)]
pub struct Account {
#[command(subcommand)]
command: AccountCommands,
}
#[async_trait]
impl RunCommand for Account {
async fn run(&self) -> anyhow::Result<()> {
self.command.run().await
}
}
```
##### `query.rs`:
```rs
#[derive(Debug, Args)]
pub struct QueryCommand { /* params */ }
#[async_trait::async_trait]
impl RunCommand for QueryCommand {
async fn run(&self) -> anyhow::Result<()> {
// Your command implementation here!
}
}
```
##### `mod.rs`
Finally tying it all together:
```rs
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
#[command(subcommand)]
command: Option<Commands>,
// Any global options that apply to all commands go below!
#[arg(short, long)]
pub verbosity: Option<String>,
}
#[async_trait::async_trait]
impl RunCommand for Cli {
async fn run(&self) -> anyhow::Result<()> {
if let Some(command) = &self.command {
command.run().await?;
exit(0);
} else {
Ok(())
}
}
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Account(Account),
Query(QueryCommand),
}
#[async_trait::async_trait]
impl RunCommand for Commands {
async fn run(&self) -> anyhow::Result<()> {
match self {
Commands::Account(account) => account.run().await,
Commands::Query(query) => query.run().await,
}
}
}
```
Now, in `main` all you have to do is call your trait:
```rs
let cli = Cli::parse();
cli.run().await.unwrap();
```
I love the filesystem-routing like feel of this. Whenever you see a command in
the CLI, you immediately know what file to go to if you need to change or fix
something. Please don't discount the value of this! As your application grows,
it can be a massive headache to come across a bug and then having to dig around
the entire codebase to find where that code is coming from. If all commands have
a particular file, you can always start your search from that file and use "go
to definition" to dig into the implementation.
This also helps if you are adding a new command, it's immediately obvious where
the code needs to go. Simple but elegant, and a significant productivity boost.

@ -1 +1 @@
Subproject commit 9782b57a9195096534a4326c54b8289b9b4e928f
Subproject commit 229f9bdc776745b692af194e56f38107045e4e8b