add post about building rust CLIs
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
This commit is contained in:
parent
bd9ccce5fb
commit
0bcc672bbe
|
@ -8,7 +8,7 @@ steps:
|
|||
build:
|
||||
image: klakegg/hugo:ext-alpine
|
||||
commands:
|
||||
- hugo
|
||||
- hugo
|
||||
search:
|
||||
image: seriousbug/pagefind
|
||||
commands:
|
||||
|
|
|
@ -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
|
Loading…
Reference in a new issue