Ryan Chandler

Setting up the command-line interface

3 min read

This post is part of the "Rebuilding Composer in Rust" series.

Welcome to my new series of blog posts where I go through the various steps that it takes to rebuild Composer in Rust.

This project is purely for fun – I don't intend to release this as an officially supported tool. If you've read some of my more recent blog posts, you can probably tell I'm in my "programming is fun, right?" phase.

So, the first thing that I need to do is get a command-line application running. The most popular CLI framework in the Rust world is clap, so I'm going to use that because who in this world would want to roll their own argparse in 2024?

Important detail upfront – this project is going to be a monorepo. I'm going to use Cargo's workspace feature which lets me separate out logic into separate packages so I can pull in various bits and pieces when I need them.

[workspace]
members = [
    "crs"
]
resolver = "2"

That's all it takes to create a monorepo of different packages. Nifty!

The crs package is going to be the main binary for the project, so that's where I'm going to start building out the command-line interface.

Installing clap is as simple as running a single command.

cargo add clap --package crs --features derive

The --features flag lets me enable conditionally compiled code from the clap package. In this case, I want to use the fancy wancy #[derive()] macro that it provides to write nice fluent command definitions.

To actually define the arguments, I need to create a struct with some fields and add a single #[derive()] attribute.

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Arguments {
    #[clap(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    Require(RequireCommand),
}

#[derive(Parser)]
struct RequireCommand {
    package: String,
}

fn main() {
    let args = Arguments::parse();

    match &args.command {
        Command::Require(cmd) => {
            println!("require {}", cmd.package);
        },
    }
}

So now, if I were to run the project in the terminal:

cargo run --package crs -- require hello

I'll get something like this in the output:

$> cargo run --package crs -- require hello
require hello

The magic is all in the #[derive(Parser)] and #[derive(Subcommand)] macros. Rust macros are absolute wizardry.

In this case, those macros are looking at the struct or enum and implementing magical methods that handle parsing the arguments.

That can be seen in the call to Arguments::parse(). That static method is being implemented by #[derive(Parser)].

Cool stuff done. Boring stuff now – code organisation. I don't want to write everything inside of a giant match block.

My personal preference is a new cmd module that exports a function for each command, e.g. cmd/require.rs would look like this.

use crate::RequireCommand;

pub fn require(cmd: &RequireCommand) {

}

This is a nice pattern in my opinion. Keeps the commands in separate files and everything related to a command can be co-located.

There we have it. A basic command-line application in Rust, setup in less than 5 minutes. Not bad!

The next post is going to be focused on making HTTP requests. That's a pretty essential part of building the require command.