diff --git a/.woodpecker.yml b/.woodpecker.yml index 376610d..f6ba1a1 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -8,7 +8,7 @@ steps: build: image: klakegg/hugo:ext-alpine commands: - - hugo + - hugo search: image: seriousbug/pagefind commands: diff --git a/content/posts/2023.12.09.building-well-organized-rust-CLI-tool.md b/content/posts/2023.12.09.building-well-organized-rust-CLI-tool.md new file mode 100644 index 0000000..8f15100 --- /dev/null +++ b/content/posts/2023.12.09.building-well-organized-rust-CLI-tool.md @@ -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, + // Any global options that apply to all commands go below! + #[arg(short, long)] + pub verbosity: Option, +} + +#[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. diff --git a/themes/catafalque b/themes/catafalque index 9782b57..229f9bd 160000 --- a/themes/catafalque +++ b/themes/catafalque @@ -1 +1 @@ -Subproject commit 9782b57a9195096534a4326c54b8289b9b4e928f +Subproject commit 229f9bdc776745b692af194e56f38107045e4e8b