I maintain many command line tools at work and for personal projects, like Hatch. A good user experience requires that installation is easy and reliable on all platforms, and I’ve found that macOS is the most difficult to support.
This guide will not cover how to get your project on Homebrew as that is already fairly well documented with plenty of examples. Instead, I will assume you want to distribute your project as both a standalone binary and an installer package (.pkg file).
I’ll assume the CLI has already been built and is called rusty (harkening back to a previous post).
opensslrcodesignCreating the installer package is the only part that requires being on macOS.
The standalone binary and installer package each need to be signed or else Gatekeeper will block them.
We will need to create two types of signing certificates:
Developer ID ApplicationDeveloper ID InstallerThe process is the same for both:
Go to the Apple Developer Portal and click on the Certificates link:

Create a private key:
openssl genrsa -out private_key_application.pem 2048
Create a CSR from the private key:
rcodesign generate-certificate-signing-request --pem-source private_key_application.pem --csr-pem-path csr.pem
Start creating the Developer ID Application certificate:


Choose the G2 Sub-CA option and upload the csr.pem file:

You should see the following page with the ability to download the certificate:

The certificate you download is in the binary DER format (.cer), which isn’t very portable. Let’s turn it into a PEM file:
openssl x509 -in developerID_application.cer -inform DER -out certificate_application.pem -outform PEM
The private_key_application.pem and certificate_application.pem files are all we need, permanently delete the following files:
csr.pemdeveloperID_application.cerThe process is the same as above, first create a CSR:
openssl genrsa -out private_key_installer.pem 2048
rcodesign generate-certificate-signing-request --pem-source private_key_installer.pem --csr-pem-path csr.pem
Then start the Developer ID Installer certificate creation process:


Upload the CSR:

Download the certificate:

Finalize the certificate:
openssl x509 -in developerID_installer.cer -inform DER -out certificate_installer.pem -outform PEM
Permanently delete the following files:
csr.pemdeveloperID_installer.cerNext we need to create an API key for Apple’s notary service. Like code signing, notarization is also required by Gatekeeper.
Go back to the Apple Developer Portal and click on the Users and Access link:

Click on the Integrations tab and then the App Store Connect API option under the Keys section to begin the creation process. Make sure to copy the Issuer ID as we will need that later.

Choose a name for the key and make sure it has Developer access:

Download the key and also copy the Key ID as we will need that later:

The downloaded private key will be named AuthKey_<Key ID>.p8. Run the following command to save the information thus far into a single JSON file for better portability, with the <Issuer ID> and <Key ID> placeholders replaced by the values you copied earlier:
rcodesign encode-app-store-connect-api-key -o app_store_connect_api_key.json "<Issuer ID>" "<Key ID>" "AuthKey_<Key ID>.p8"
This will create a file called app_store_connect_api_key.json with the following content:
{
"issuer_id": "<Issuer ID>",
"key_id": "<Key ID>",
"private_key": "..."
}
Permanently delete the AuthKey_<Key ID>.p8 file and wherever you noted the Issuer ID and Key ID (although the latter two are not sensitive data).
Now that we have the necessary Apple credentials, we can sign and notarize the binaries. All binaries must go through the following steps.
First, sign each binary in-place using the Developer ID Application certificate and associated private key:
rcodesign sign --pem-source certificate_application.pem --pem-source private_key_application.pem --code-signature-flags runtime rusty
The --code-signature-flags runtime enables the Hardened Runtime capability, which is a requirement for notarization.
Then, submit the binary to Apple’s notary service:
rcodesign notary-submit --api-key-path app_store_connect_api_key.json rusty
I’ve encountered a fair amount of flakiness with Apple’s notary service so I’d recommend setting the --max-wait-seconds to something quite long like 3600 seconds (1 hour). Additionally, you may even want to run such notarization steps in CI multiple times after you get everything set up once.
These steps require being on macOS.
If you wish to support users on both Apple Silicon and the older Intel architectures, you will need to build a universal binary. Assuming you have the following binaries:
rusty-aarch64-apple-darwinrusty-x86_64-apple-darwinYou can create a universal binary with the following lipo command:
lipo -create -output rusty-universal rusty-aarch64-apple-darwin rusty-x86_64-apple-darwin
This will create a rusty-universal binary that can be used on both architectures and is what the installer package will contain.
Finally, ensure the binary has the correct permissions:
chmod 755 rusty-universal
The following values will be used to create the installer package:
<IDENTIFIER>: The ID of the distribution, usually the reverse-DNS of the project e.g. com.example.rusty. This can be whatever you want as long as it uniquely refers to the project.<VERSION>: The version of the project e.g. 1.2.3.If your project has a logo, I’d recommend creating an image that will be displayed on the installation window’s sidebar. The image’s height should be approximately 2.5x its width. The dimensions I use for Hatch and other projects’ macOS installer image are 1390x3680 (example).
Finally, create a temporary directory whose absolute path we will refer to as <TEMP_DIR>.
We need to create a component package with a structure that mimics the desired installation structure. A component package is a file ending in .pkg that will be nested inside the actual .pkg installer package.
Create a directory <TEMP_DIR>/root, whose absolute path we will refer to as <ROOT_DIR>. The structure should be as follows:
root
├── etc
│ └── paths.d
│ └── rusty
└── usr
└── local
└── bin
└── rusty
The <ROOT_DIR>/usr/local/bin/rusty file should be the standalone binary users will run (or the universal binary if you created one).
The <ROOT_DIR>/etc/paths.d/rusty file should contain the path to the binary on the user’s machine:
/usr/local/bin/rusty
Adding this file to /etc/paths.d will ensure the binary is found when the user runs rusty from the command line.
Under some circumstances, a user’s shell may require extra configuration to properly respect path_helper. I’ve seen that with Nix users a few times.
Create a directory <TEMP_DIR>/components, whose absolute path we will refer to as <COMPONENTS_DIR>. Then run the following command to create the component package, with the placeholders replaced by the values from earlier:
pkgbuild --root "<ROOT_DIR>" --identifier "<IDENTIFIER>" --version "<VERSION>" --install-location / "<COMPONENTS_DIR>/<IDENTIFIER>.pkg"
This will create the component package file named <IDENTIFIER>.pkg.
The distribution package is the actual installer package that users will download and install. It will contain the component package as well as some metadata.
Create a directory <TEMP_DIR>/resources, whose absolute path we will refer to as <RESOURCES_DIR>. The structure should be as follows:
resources
├── LICENSE.txt
├── README.html
└── icon.png
The LICENSE.txt file (named however you like) should be the license of what you distribute. I’d recommend simply copying the project’s license file to this location.
The README.html file will be rendered for the user upon installation. It might contain something like this:
<!-- file: "index.html" -->
<!DOCTYPE html>
<html>
<head></head>
<body>
<p>This will install Rusty v1.2.3 globally.</p>
<p>For more information, see our <a href="https://example.com/setup/">Installation Guide</a>.</p>
</body>
</html>
The optional icon.png file should be the image that was noted earlier.
Next, create a distribution definition file <TEMP_DIR>/distribution.xml. It should contain the following content with the placeholders replaced by the values from earlier:
<!-- file: "distribution.xml" -->
<?xml version="1.0" encoding="utf-8"?>
<installer-gui-script minSpecVersion="1">
<title>Rusty</title>
<license file="LICENSE.txt" mime-type="text/plain"/>
<readme file="README.html" mime-type="text/html"/>
<background mime-type="image/png" file="icon.png" alignment="left" scaling="proportional"/>
<background-darkAqua mime-type="image/png" file="icon.png" alignment="left" scaling="proportional"/>
<options hostArchitectures="arm64,x86_64" customize="never" require-scripts="false"/>
<domains enable_localSystem="true"/>
<choices-outline>
<line choice="<IDENTIFIER>.choice"/>
</choices-outline>
<choice title="Rusty (universal)" id="<IDENTIFIER>.choice">
<pkg-ref id="<IDENTIFIER>.pkg"/>
</choice>
<pkg-ref id="<IDENTIFIER>.pkg"><IDENTIFIER>.pkg</pkg-ref>
</installer-gui-script>
If there is no image, the background and background-darkAqua elements can be omitted. The hostArchitectures attribute of the options element assumes a universal binary is being distributed. If that is not the case, then modify the value to refer to a single architecture.
Now, create the installer package:
productbuild --distribution "<TEMP_DIR>/distribution.xml" --resources "<RESOURCES_DIR>" --package-path "<COMPONENTS_DIR>" rusty.pkg
This will create the final installer package file named rusty.pkg.
Sign the installer package using the Developer ID Installer certificate and associated private key:
rcodesign sign --pem-source certificate_installer.pem --pem-source private_key_installer.pem rusty.pkg
Then, submit the installer package to Apple’s notary service:
rcodesign notary-submit --api-key-path app_store_connect_api_key.json --staple rusty.pkg
The --staple flag will staple the certificate to the installer package. The binaries were not stabled because Apple currently does not support that.
In order to go through this process again (like in CI) be sure to securely save the following five credentials:
certificate_application.pemprivate_key_application.pemcertificate_installer.pemprivate_key_installer.pemapp_store_connect_api_key.jsonI recently embedded on another team at work in order to improve their testing processes. In doing so I created a CLI to provide a better developer experience and eventually a unified interface for interacting with their repository.
I have a fair amount of experience developing CLIs, such as Python’s Hatch and the tool my current team uses to maintain hundreds of packages while staying lean.
My go-to framework in Python is Click, which allows for a declarative approach to defining commands. Using their example:
# file: "hello.py"
import click
@click.command()
@click.option("--count", default=1, help="Number of greetings.")
@click.option("--name", prompt="Your name", help="The person to greet.")
def hello(count, name):
"""Simple program that greets NAME for a total of COUNT times."""
for _ in range(count):
click.echo(f"Hello, {name}!")
if __name__ == '__main__':
hello()
I quite like this style and the overall development velocity granted by Python, so I was worried about losing one or both of these. Fortunately, Clap provides similar functionality and I’m just as productive developing in Rust.
Install Rust then create an app called rusty:
cargo new rusty
cd rusty
We’ll add Clap with the derive feature for the actual CLI and anyhow for easy error propagation:
cargo add clap -F derive
cargo add anyhow
The commands themselves, which are each just a struct, will be stored in the src/commands directory. We want every command group/namespace to have its own directory with a cli module, including the root rusty command group. All commands will have an exec method that provides the actual implementation, which in the case of command groups will simply be calling sub-commands.
Create the following files:
// file: "src/commands/mod.rs"
pub mod cli;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::Parser;
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {}
impl Cli {
pub fn exec(&self) -> Result<()> {
println!("Hello, World!");
Ok(())
}
}
Then update the binary entry point:
// file: "src/main.rs"
mod commands;
use anyhow::Result;
use clap::Parser;
use crate::commands::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
cli.exec()
}
At this point the project’s structure should look like:
src
├── commands
│ ├── cli.rs
│ └── mod.rs
└── main.rs
Cargo.lock
Cargo.toml
Now let’s test the app:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty
Options:
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- --version
rusty 0.1.0
❯ cargo run -q
Hello, World!
The help text is derived from the documentation on the command we defined and the version comes from the Cargo.toml file.
Very often you’ll want to pass arguments through to other executables, like when working in Kubernetes environments. To illustrate this, we’ll add an exec sub-command that will spawn a process with the supplied arguments.
Create the following file:
// file: "src/commands/exec.rs"
use anyhow::Result;
use clap::Args;
use std::process::{exit, Command};
/// Execute an arbitrary command
///
/// All arguments are passed through unless --help is first
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
#[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
impl Cli {
pub fn exec(&self) -> Result<()> {
let mut command = Command::new(&self.args[0]);
if self.args.len() > 1 {
command.args(&self.args[1..]);
}
let status = command.status()?;
exit(status.code().unwrap_or(1));
}
}
Then add the new module:
// file: "src/commands/mod.rs"
pub mod cli;
pub mod exec;
Finally, modify the root command:
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Exec(super::exec::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Exec(cli) => cli.exec(),
}
}
}
At this point the project’s structure should look like:
src
├── commands
│ ├── cli.rs
│ ├── exec.rs
│ └── mod.rs
└── main.rs
Cargo.lock
Cargo.toml
Let’s try it:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty <COMMAND>
Commands:
exec Execute an arbitrary command
Options:
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- exec --help
Execute an arbitrary command
All arguments are passed through unless --help is first
Usage: rusty exec <ARGS>...
Arguments:
<ARGS>...
Options:
-h, --help
Print help information (use `-h` for a summary)
❯ cargo run -q -- exec ls -a
.
..
.git
.gitignore
Cargo.lock
Cargo.toml
src
target
Let’s add an option to influence the app’s verbosity.
We’ll use the clap-verbosity-flag crate:
cargo add clap-verbosity-flag
Modify the root command:
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
#[clap(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Exec(super::exec::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Exec(cli) => cli.exec(),
}
}
}
The <InfoLevel> indicates that the default level will show informational output.
Now every command can influence the output level:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty [OPTIONS] <COMMAND>
Commands:
exec Execute an arbitrary command
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- exec --help
Execute an arbitrary command
All arguments are passed through unless --help is first
Usage: rusty exec [OPTIONS] <ARGS>...
Arguments:
<ARGS>...
Options:
-v, --verbose...
More output per occurrence
-q, --quiet...
Less output per occurrence
-h, --help
Print help information (use `-h` for a summary)
Next we’ll provide a way to access the configured verbosity level globally using the once_cell crate:
cargo add once_cell
The log crate is also required for the level enums:
cargo add log
Create the following file:
// file: "src/app.rs"
use log::LevelFilter;
use once_cell::sync::OnceCell;
static VERBOSITY: OnceCell<LevelFilter> = OnceCell::new();
pub fn verbosity() -> &'static LevelFilter {
VERBOSITY.get().expect("verbosity is not initialized")
}
pub fn set_global_verbosity(verbosity: LevelFilter) {
VERBOSITY.set(verbosity).expect("could not set verbosity")
}
Then update the binary entry point:
// file: "src/main.rs"
mod app;
mod commands;
use anyhow::Result;
use clap::Parser;
use crate::commands::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
app::set_global_verbosity(cli.verbose.log_level_filter());
cli.exec()
}
At this point the project’s structure should look like:
src
├── commands
│ ├── cli.rs
│ ├── exec.rs
│ └── mod.rs
├── app.rs
└── main.rs
Cargo.lock
Cargo.toml
Now the entire app can access the configured verbosity level with:
*crate::app::verbosity()
With the verbosity set globally, we can create declarative macros that will be available throughout the app for conditionally displaying text based on the verbosity.
Create the following file:
// file: "src/macros.rs"
macro_rules! define_display_macro {
($name:ident, $level:ident, $d:tt) => {
macro_rules! $name {
($d($d arg:tt)*) => {
if log::Level::$level <= *$crate::app::verbosity() {
eprintln!($d($d arg)*);
}
};
}
};
}
define_display_macro!(trace, Trace, $);
define_display_macro!(debug, Debug, $);
define_display_macro!(info, Info, $);
define_display_macro!(warn, Warn, $);
define_display_macro!(error, Error, $);
Here we’re maximizing boilerplate reduction by defining a single macro that will generate the macros the app will actually use.
The $ hack is a workaround for a limitation that is being worked on (see rust-lang/rust#35853 and rust-lang/rust#83527).
Then add the module to the very top of the binary entry point preceded by #[macro_use]:
// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;
use anyhow::Result;
use clap::Parser;
use crate::commands::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
app::set_global_verbosity(cli.verbose.log_level_filter());
cli.exec()
}
Now let’s create a hidden sub-command to test the macros:
// file: "src/commands/test.rs"
use anyhow::Result;
use clap::Args;
/// Test conditional output
#[derive(Args, Debug)]
#[command(hide = true)]
pub struct Cli {
text: String,
}
impl Cli {
pub fn exec(&self) -> Result<()> {
trace!("trace {}", self.text);
debug!("debug {}", self.text);
info!("info {}", self.text);
warn!("warn {}", self.text);
error!("error {}", self.text);
Ok(())
}
}
Then add the new module:
// file: "src/commands/mod.rs"
pub mod cli;
pub mod exec;
pub mod test;
Finally, add it to the root command:
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
#[clap(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Exec(super::exec::Cli),
Test(super::test::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Exec(cli) => cli.exec(),
Commands::Test(cli) => cli.exec(),
}
}
}
At this point the project’s structure should look like:
src
├── commands
│ ├── cli.rs
│ ├── exec.rs
│ ├── mod.rs
│ └── test.rs
├── app.rs
├── macros.rs
└── main.rs
Cargo.lock
Cargo.toml
Let’s try it out:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty [OPTIONS] <COMMAND>
Commands:
exec Execute an arbitrary command
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- test hello
info hello
warn hello
error hello
❯ cargo run -q -- test hello -vv
trace hello
debug hello
info hello
warn hello
error hello
❯ cargo run -q -- test hello -qq
error hello
Now let’s apply styling to the output levels using owo-colors with the supports-colors feature for conditional use based on TTY detection:
cargo add owo-colors -F supports-colors
Update the macros and the test command:
// file: "src/macros.rs"
macro_rules! display {
($($arg:tt)*) => {{
use owo_colors::OwoColorize;
println!(
"{}",
format!($($arg)*)
.if_supports_color(owo_colors::Stream::Stdout, |text| text.bold())
);
}};
}
macro_rules! critical {
($($arg:tt)*) => {{
use owo_colors::OwoColorize;
eprintln!(
"{}",
format!($($arg)*)
.if_supports_color(owo_colors::Stream::Stderr, |text| text.bright_red())
);
}};
}
macro_rules! define_display_macro {
($name:ident, $level:ident, $style:ident, $d:tt) => (
macro_rules! $name {
($d($d arg:tt)*) => {{
use owo_colors::OwoColorize;
if log::Level::$level <= *$crate::app::verbosity() {
eprintln!(
"{}",
format!($d($d arg)*)
.if_supports_color(owo_colors::Stream::Stderr, |text| text.$style())
);
}
}};
}
);
}
define_display_macro!(trace, Trace, underline, $);
define_display_macro!(debug, Debug, italic, $);
define_display_macro!(info, Info, bold, $);
define_display_macro!(success, Info, bright_cyan, $);
define_display_macro!(waiting, Info, bright_magenta, $);
define_display_macro!(warn, Warn, bright_yellow, $);
define_display_macro!(error, Error, bright_red, $);
For standard or informational output we use bold rather than bright white for terminals with white backgrounds.
// file: "src/commands/test.rs"
use anyhow::Result;
use clap::Args;
/// Test conditional output
#[derive(Args, Debug)]
#[command(hide = true)]
pub struct Cli {
text: String,
}
impl Cli {
pub fn exec(&self) -> Result<()> {
trace!("trace {}", self.text);
debug!("debug {}", self.text);
info!("info {}", self.text);
success!("success {}", self.text);
waiting!("waiting {}", self.text);
warn!("warn {}", self.text);
error!("error {}", self.text);
display!("display {}", self.text);
critical!("critical {}", self.text);
Ok(())
}
}
The display!/critical! macros provide a way to always output using println!/eprintln! but with color.
❯ cargo run -q -- test hello -qqq
display hello
critical hello
Now we’ll persist app settings using confy and serde with the derive feature:
cargo add serde -F derive
cargo add confy
Create the following file:
// file: "src/config.rs"
use anyhow::{Context, Result};
use confy;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const APP_NAME: &str = "rusty";
const FILE_STEM: &str = "config";
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct Config {
pub repo: String,
}
impl Default for Config {
fn default() -> Self {
Self {
repo: "".into(),
}
}
}
pub fn path() -> Result<PathBuf> {
confy::get_configuration_file_path(APP_NAME, FILE_STEM)
.with_context(|| "unable to find the config file")
}
pub fn load() -> Result<Config> {
confy::load(APP_NAME, FILE_STEM).with_context(|| "unable to load config")
}
pub fn save(config: Config) -> Result<()> {
confy::store(APP_NAME, FILE_STEM, config).with_context(|| "unable to save config")
}
Here we defined a single option named repo. Now let’s load the configuration and provide global access to it like we did for the verbosity.
Edit the following file:
// file: "src/app.rs"
use log::LevelFilter;
use once_cell::sync::OnceCell;
use crate::config::Config;
static VERBOSITY: OnceCell<LevelFilter> = OnceCell::new();
static CONFIG: OnceCell<Config> = OnceCell::new();
pub fn verbosity() -> &'static LevelFilter {
VERBOSITY.get().expect("verbosity is not initialized")
}
pub fn config() -> &'static Config {
CONFIG.get().expect("config is not initialized")
}
pub fn set_global_verbosity(verbosity: LevelFilter) {
VERBOSITY.set(verbosity).expect("could not set verbosity")
}
pub fn set_global_config(config: Config) {
CONFIG.set(config).expect("could not set config")
}
Then update the binary entry point:
// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;
mod config;
use anyhow::Result;
use clap::Parser;
use crate::commands::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
app::set_global_verbosity(cli.verbose.log_level_filter());
app::set_global_config(config::load()?);
cli.exec()
}
At this point the project’s structure should look like:
src
├── commands
│ ├── cli.rs
│ ├── exec.rs
│ ├── mod.rs
│ └── test.rs
├── app.rs
├── config.rs
├── macros.rs
└── main.rs
Cargo.lock
Cargo.toml
Now the entire app can access the loaded configuration with:
crate::app::config()
Before we procede, let’s add a utility for resolving a path that works around a long-standing issue on Windows.
We’ll use the dunce crate:
cargo add dunce
Create the following file:
// file: "src/platform.rs"
pub fn canonicalize_path(path: &String) -> String {
match dunce::canonicalize(path) {
Ok(p) => p.display().to_string(),
Err(_) => path.to_string(),
}
}
Then add the module to the binary entry point:
// file: "src/main.rs"
#[macro_use]
mod macros;
mod app;
mod commands;
mod config;
mod platform;
use anyhow::Result;
use clap::Parser;
use crate::commands::cli::Cli;
fn main() -> Result<()> {
let cli = Cli::parse();
app::set_global_verbosity(cli.verbose.log_level_filter());
app::set_global_config(config::load()?);
cli.exec()
}
Let’s now add an interface for managing the configuration.
First create the following files:
// file: "src/commands/config/mod.rs"
pub mod cli;
pub mod find;
pub mod set;
// file: "src/commands/config/cli.rs"
use anyhow::Result;
use clap::{Args, Subcommand};
/// Manage the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Find(super::find::Cli),
Set(super::set::cli::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Find(cli) => cli.exec(),
Commands::Set(cli) => cli.exec(),
}
}
}
// file: "src/commands/config/find.rs"
use anyhow::Result;
use clap::Args;
use crate::config;
/// Locate the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {}
impl Cli {
pub fn exec(&self) -> Result<()> {
display!("{}", config::path()?.display());
Ok(())
}
}
// file: "src/commands/config/set/mod.rs"
pub mod cli;
pub mod repo;
// file: "src/commands/config/set/cli.rs"
use anyhow::Result;
use clap::{Args, Subcommand};
/// Modify the config file
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Repo(super::repo::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Repo(cli) => cli.exec(),
}
}
}
// file: "src/commands/config/set/repo.rs"
use anyhow::Result;
use clap::Args;
use crate::{app, config, platform};
/// Set the path to the repository
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
path: String,
}
impl Cli {
pub fn exec(&self) -> Result<()> {
let path = platform::canonicalize_path(&self.path);
debug!("Setting repository path to: {}", &path);
let mut config = app::config().clone();
config.repo = path;
config::save(config)?;
Ok(())
}
}
Then add the new module and update the root command:
// file: "src/commands/mod.rs"
pub mod cli;
pub mod config;
pub mod exec;
pub mod test;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
#[clap(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Config(super::config::cli::Cli),
Exec(super::exec::Cli),
Test(super::test::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Config(cli) => cli.exec(),
Commands::Exec(cli) => cli.exec(),
Commands::Test(cli) => cli.exec(),
}
}
}
At this point the project’s structure should look like:
src
├── commands
│ ├── config
│ │ ├── set
│ │ │ ├── cli.rs
│ │ │ ├── mod.rs
│ │ │ └── repo.rs
│ │ ├── cli.rs
│ │ ├── find.rs
│ │ └── mod.rs
│ ├── cli.rs
│ ├── exec.rs
│ ├── mod.rs
│ └── test.rs
├── app.rs
├── config.rs
├── macros.rs
├── main.rs
└── platform.rs
Cargo.lock
Cargo.toml
Let’s try it out:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty [OPTIONS] <COMMAND>
Commands:
config Manage the config file
exec Execute an arbitrary command
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- config --help
Manage the config file
Usage: rusty config [OPTIONS] <COMMAND>
Commands:
find Locate the config file
set Modify the config file
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
❯ cargo run -q -- config set repo . -v
Setting repository path to: C:\Users\ofek\Desktop\code\rusty
Let’s offer shell completion using clap_complete:
cargo add clap_complete
Create the following file:
// file: "src/commands/complete.rs"
use anyhow::Result;
use clap::{Args, CommandFactory};
use clap_complete::{generate, Shell};
use std::io;
use crate::commands::cli::Cli as RootCli;
/// Display the completion file for a given shell
#[derive(Args, Debug)]
#[command()]
pub struct Cli {
#[arg(value_enum)]
shell: Shell,
}
impl Cli {
pub fn exec(&self) -> Result<()> {
let mut cmd = RootCli::command();
let bin_name = cmd.get_name().to_string();
generate(self.shell, &mut cmd, bin_name, &mut io::stdout());
Ok(())
}
}
Then add the new module and update the root command:
// file: "src/commands/mod.rs"
pub mod cli;
pub mod complete;
pub mod config;
pub mod exec;
pub mod test;
// file: "src/commands/cli.rs"
use anyhow::Result;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
/// Rusty example app
#[derive(Parser, Debug)]
#[command(version, bin_name = "rusty", disable_help_subcommand = true)]
pub struct Cli {
#[clap(flatten)]
pub verbose: Verbosity<InfoLevel>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Complete(super::complete::Cli),
Config(super::config::cli::Cli),
Exec(super::exec::Cli),
Test(super::test::Cli),
}
impl Cli {
pub fn exec(&self) -> Result<()> {
match &self.command {
Commands::Complete(cli) => cli.exec(),
Commands::Config(cli) => cli.exec(),
Commands::Exec(cli) => cli.exec(),
Commands::Test(cli) => cli.exec(),
}
}
}
Let’s see:
❯ cargo run -q -- --help
Rusty example app
Usage: rusty [OPTIONS] <COMMAND>
Commands:
complete Display the completion file for a given shell
config Manage the config file
exec Execute an arbitrary command
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
-V, --version Print version information
❯ cargo run -q -- complete --help
Display the completion file for a given shell
Usage: rusty complete [OPTIONS] <SHELL>
Arguments:
<SHELL> [possible values: bash, elvish, fish, powershell, zsh]
Options:
-v, --verbose... More output per occurrence
-q, --quiet... Less output per occurrence
-h, --help Print help information
Now you should be all set to implement your own command line applications using the same strategies and directory structure described. The final code lives here.
]]>Do not expect regular content; I will only dedicate blog posts to tutorials or to topics that are particularly interesting 😀
]]>