Learning Rust: Migrating Command-Line Rust from Clap 2 to Clap 4

Posted on Jan 29, 2023

Update 13.02.2023 The author created a branch for clap v4 but still using the builder API. Also, since writing this, the author started to work on a branch converting the examples to the clap v4 derive API.

Based on clarifications from epage (maintainer for clap) on reddit, I corrected that the builder API is “lower-level”, not “older”

To get my learning journey with Rust started, I’ve been working through the book Command-Line Rust by Ken Youens-Clark. The book teaches Rust beginners by guiding them through building well-known GNU / BSD command line tools such as echo, cat, and grep. For me, the book was a great starting point with Rust. The author reduces hand-holding from chapter to chapter and CLI applications are so easy conceptually, that you can concentrate on language features. But a book review is not the purpose of this article …

A big part of command line tools is parsing arguments, for which the book uses the clap package. Clap is an amazing library to learn but its API changed drastically (currently version 4.1) compared to clap version 2 used in the book. I would definitely recommend to use clap 4 if building a CLI today. As a learning exercise, I migrated the first few programs from the book to clap 4. You can directly jump to my repo here or read on for an API comparison of the clap 2 and clap 4 API.

Comparing Clap 2 vs Clap 4 API at the example of GNU cat

Below you can see the command line parsing for my incomplete cat version, first, with the clap 2 and the builder API, and then with clap 4 and the derive API. It’s clear at first glance that the clap 4 version with the derive API is more concise but also includes more magic. For example, since the struct field files in the clap 4 version is a Vec type, clap can derive that this argument takes multiple values. Also, the triple / are special comments that get parsed as help text for the CLI.

I like the derive API a lot, it reminds me of how typer and FastAPI by Sebastián Ramírez leverage type hints in the Python world.

However, I also have some struggles with it. For example, I cannot use auto-complete and other LSP/IDE features with macros like #[command(...)]. I don’t know whether this is a general thing with Rust macros or whether I have to tweak my neovim setup. Also, many code examples for clap are still based on the lower level builder API and I sometimes struggle to find my way around in the derive API documentation (I’m not the only one struggling with this. There is a pinned GitHub discussion with clarifications here).

Resources that have been helpful for me, are: the derive tutorial, the examples in the cookbook although there are still more examples for the builder API than for derive, and searching code examples on Github to see how others are using the derive API.

Clap 2 - Builder API

use clap::{App, Arg};

#[derive(Debug)]
pub struct Config {
    files: Vec<String>,
    number_lines: bool,
    number_nonblank_lines: bool,
    show_ends: bool,
}

pub fn get_args() -> ProgResult<Config> {
    let matches = App::new("catr")
        .version("0.1.0")
        .author("Simon Weiß")
        .about("Incomplete `GNU cat` in Rust for learning purposes")
        .arg(
            Arg::with_name("files")
                .value_name("FILES")
                .help("Files to cat")
                .multiple(true)
                .default_value("-"),
        )
        .arg(
            Arg::with_name("number_lines")
                .long("number")
                .short("-n")
                .help("Print line numbers")
                .takes_value(false),
        )
        .arg(
            Arg::with_name("number_nonblank_lines")
                .long("number-nonblank")
                .short("-b")
                .help("Print line numbers for nonblank lines")
                .takes_value(false)
                .conflicts_with("number_lines"),
        )
        .arg(
            Arg::with_name("show_ends")
                .long("show-ends")
                .short("-E")
                .help("Show $ at the end of each line")
                .takes_value(false),
        )
        .get_matches();
    Ok(Config {
        files: matches.values_of_lossy("files").unwrap(),
        number_lines: matches.is_present("number_lines"),
        number_nonblank_lines: matches.is_present("number_nonblank_lines"),
        show_ends: matches.is_present("show_ends"),
    })
}

Clap 4 - Derive API

#[derive(Parser, Debug)]
#[command(
    author = "Simon Weiß",
    version,
    about = "Incomplete `GNU cat` in Rust for learning purposes"
)]
pub struct Config {
    /// Files to cat
    #[arg(name = "FILES", default_value = "-")]
    files: Vec<String>,
    /// Print line numbers
    #[arg(short, long = "number")]
    number_lines: bool,
    /// Print line numbers for nonblank lines
    #[arg(short = 'b', long = "number-nonblank", conflicts_with = "number_lines")]
    number_nonblank_lines: bool,
    /// Show $ at the end of each line
    #[arg(short = 'E', long = "show-ends")]
    show_ends: bool,
}

Conclusion

I hope this might ease the transition to clap 4 for a few Rust learners. I think it is a good idea to switch to the newest version of clap to implement the examples from the book (maybe I’d use clap 2 for the first two chapters to stay close to the book and have an easy start). The tests from the book-accompanying repo provide a great safety net and you can be certain to have implemented everything correctly. I’m mostly learning with other resources now (currently codecrafters.io), but if I continue going through the book examples with clap 4, I’ll push the code to this repo.