Minimal Rust Binary Project

When you develop your first binary application using new language, the first issue that you face is how to organize your code so that you can easily extend it in the future. There is a good example in The Book on how to organize your code, parse and process command line arguments by yourself. However, in real world you would use a library to parse command line arguments, which most probably would be the clap library in case of Rust. In this article, I describe my template for creating a CLI (command line interface) application.

Table of Contents

Requirements

I think that the recommendations given in The Book how to separate code into library and binary parts are very useful. So, in my approach I base on this ground.

To process command line arguments and options I use the clap library. In this article, I experiment with the latest beta version (3.0.0-beta.2) of the crate.

In any application, you need to understand what happens underneath, that is why logging should be its integral part. Of course, you can use print! and eprint! family of macroses to write to standard output and error descriptors. However, it is easier to use a framework to facilitate logging. I employ the log4rs crate of the version 1.0.0-alpha-2.

As it is stressed in The Book, the application binary part (located in main.rs) should be minimal. It is supposed to parse command line arguments, to convert them into the library configuration and to call the run function of the library passing this configuration as a parameter. In addition, we can put there the code to set up logging, because it is supposed to be configured in the very start of the application execution.

Usually, in a binary application we provide the way to increase the level of logging verbosity in order to locate precisely the source of a problem if it happens. Personally, I prefer to do this through a special flag that can be provided several times, each time increasing the level of details. Clearly, these logging level is not a part of the library configuration, so should not be used there.

Binary Project Backbone

With all these requirements in mind, let’s imagine how the structure of the project may look like. As we have already discussed there should be library and binary crates in the same package. Let’s at first create a binary crate:

$ cargo new minimal_example

Now, let’s add a file that will contain our library core:

$ cd minimal_example/
$ touch src/lib.rs

In addition, let’s extract the structure describing command line interface and relevant code into a separate module:

$ touch src/cli.rs

After these actions, the project should have the following structure:

$ tree minimal_example/
minimal_example/
├── Cargo.toml
└── src
    ├── cli.rs
    ├── lib.rs
    └── main.rs

Simple Binary Project Code

In this simple binary project, we will write a boilerplate code that you can later extend according to your needs. At first, let’s add necessary dependencies by modifying the Cargo.toml file:

[package]
name = "minimal_example"
version = "0.1.0"
authors = ["Yury"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
clap = {version="3.0.0-beta.2", features=["wrap_help"]}
log = "^0.4"
log4rs = "1.0.0-alpha-2"

Look at the [dependencies] section:

  • the clap crate is required for parsing command line arguments,
  • the log4rs crate provides a logging framework,
  • while the log crate is a simple facade to the logging framework.

Let’s start designing our application from the library crate in the lib.rs file. This approach is typical: at first you decide what functionality is required in your application and implement it in the form of functions, and then you create pipelines from these functions that are controlled by command line arguments and functions. For the sake of brevity, let’s implement only minimal required functionality there:

use log::info;
pub struct Config {
    pub arg: String,
}

pub fn run(config: &Config) {
    info!("{}", config.arg);
}

Here, we define the structure called Config with one arg field. For the sake of brevity, we made this field public. In a real case, it is better to make it private and create a function, usually called new, that will create an instance of this structure. The simple run function is the entry point in our library. We will call this function from the main function passing a reference to the Config instance describing the parameters of a launch.

In order to describe command line arguments and options, we use a structure which parts are annotated with special clap annotations. So as the goal of this post is to show the code of a typical minimal Rust project, I do not describe in details what different clap annotations mean. In short, the structure Opts, describing command line interface of the application, contains two fields: the first corresponds to a simple required positional argument and the second corresponds to an option describing the level of verbosity. We put this structure in a separate cli module located in the cli.rs file:

use clap::{Clap, crate_version};

/// This doc string acts as a help message when the user runs '--help'
/// as do all doc strings on fields
#[derive(Clap)]
#[clap(version = crate_version!(), author = "Yury")]
pub struct Opts {
    /// Defines a simple required argument
    pub opt: String,
    /// A level of verbosity, and can be used multiple times
    #[clap(short, long, parse(from_occurrences))]
    pub verbose: u8,
}


use minimal_example::Config;

impl From<Opts> for Config {
    fn from(opts: Opts) -> Self {
        Config {
            opt: opts.opt,
        }
    }
}

In the second half of the module, we implement From<Opts> trait in order to convert cli options into the application configuration. It consumes the Opts instance, thus we do not spend additional memory after the conversion.

With all these bits in place, we are ready to provide the code for our binary crate. Here is the content of the main.rs file:

mod cli;

use clap::Clap;
use log::LevelFilter;
use log4rs::{Config, Handle, append::console::ConsoleAppender, config::{Appender, Root}, encode::pattern::PatternEncoder};
use minimal_example::Config as MEConfig;

fn main() {
    let opts = cli::Opts::parse();
    let _ = setup_logging(opts.verbose);
    
    let config = MEConfig::from(opts);
    minimal_example::run(&config);
}

fn setup_logging(level: u8) -> Handle {
    let stdout = ConsoleAppender::builder()
        .encoder(Box::new(PatternEncoder::new(
            "{h({d(%H:%M:%S.%3f)} [{l}]:)} {m}{n}",
        )))
        .build();
    
    let log4rs_config = Config::builder()
        .appender(Appender::builder().build("stdout", Box::new(stdout)))
        .build(Root::builder().appender("stdout").build(match level {
            0 => LevelFilter::Warn,
            1 => LevelFilter::Info,
            2 => LevelFilter::Debug,
            3 | _ => LevelFilter::Trace,
        }))
        .unwrap();
    
    log4rs::init_config(log4rs_config).unwrap()
}

The application implemented by our binary crate is closely connected with the command line interface. Therefore, importing the cli module here (use cli;) is logical. In the main function, which is an entry point of our application, we call the parse derived method that transforms command line arguments into the instance of the Opts structure. The logging is set up in the setup_logging function. The setup is very simple: it sets a custom format of the output and based on the number of -v flags changes the level of verbosity. By default, the application will output only Warn and Error messages if no -v flags are provided. The setup_logging function returns a handler that can be later used to modify the logging setup. After that, we convert our Opts instance into the Config instance from our library using the implemented from method from the From trait and call our library entry function minimal_example::run.

If you have your own approach of starting a new Rust binary project, I will be glad if you would share it with me! As I am a novice in Rust, I would like to learn the best practices.

Yury Zhauniarovich
Yury Zhauniarovich
R&D Engineer
Lead Data Scientist
Cyber Security Researcher

Related