This guide provides best practices and patterns for developing sophisticated, user-friendly command-line interface (CLI) tools in Rust, drawing examples from the sapphire-cli
project.
- Discoverability: Easy to find commands and options (e.g.,
--help
). - Feedback: Keep the user informed about what's happening (e.g., spinners, progress bars, status messages).
- Clarity: Use clear language, consistent terminology, and well-formatted output.
- Robustness: Handle errors gracefully and provide informative error messages.
- Efficiency: Be reasonably fast and resource-efficient.
A well-organized project is easier to maintain and extend. The sapphire-cli
structure provides a good template:
-
src/main.rs
:- Entry Point: The main function where execution begins.
- Argument Parsing: Parses top-level arguments using
clap
. - Initialization: Sets up logging (
tracing
), loads configuration (Config
), initializes shared resources likeCache
. - Global Logic: Handles tasks relevant to all commands (like the auto-update check).
- Command Dispatch: Delegates execution to the appropriate subcommand's
run
method.
-
src/cli.rs
:- Root CLI Definition: Defines the main
clap::Parser
struct (e.g.,CliArgs
) and theclap::Subcommand
enum (e.g.,Command
). - Submodule Aggregation: Declares
mod
for each command submodule (e.g.,mod install;
). - Central Dispatch Logic: Often contains the top-level
run
method that matches on theCommand
enum and calls the specific command'srun
.
- Root CLI Definition: Defines the main
-
src/cli/
(Directory):- Command Modules: Each subcommand gets its own file (e.g.,
install.rs
,search.rs
,info.rs
). - Command-Specific Logic:
- Defines a
clap::Args
struct for the subcommand's specific arguments (e.g.,struct Install { ... }
). - Contains the primary
async fn run(&self, config: &Config, cache: Arc<Cache>) -> Result<()>
method implementing the command's functionality. - May contain helper functions specific to that command.
- Defines a
- Command Modules: Each subcommand gets its own file (e.g.,
-
src/ui.rs
: (Optional but Recommended)- UI Helpers: Centralizes functions for creating UI elements like spinners (
indicatif::ProgressBar
), formatting text, etc. Promotes consistency. Example:create_spinner
.
- UI Helpers: Centralizes functions for creating UI elements like spinners (
-
src/error.rs
: (If using custom errors)- Defines the application's custom error enum, often using
thiserror
.
- Defines the application's custom error enum, often using
Benefits:
- Clear separation of concerns.
- Easy to locate code for specific commands.
- Reduces complexity in
main.rs
andcli.rs
.
clap
is the de facto standard for argument parsing in Rust.
- Declarative: Define your CLI structure using structs and attributes (
#[derive(Parser)]
,#[derive(Args)]
,#[arg(...)]
). - Automatic Help: Generates
--help
and--version
messages automatically. - Subcommands: Easily define nested commands (like
sapphire install <name>
). - Type Safety: Parses arguments into specified Rust types.
Example (cli.rs
and cli/install.rs
):
// src/cli.rs
use clap::{Parser, Subcommand};
// ... other imports
#[derive(Parser, Debug)]
pub struct CliArgs {
#[arg(short, long, action = ArgAction::Count, global = true)]
pub verbose: u8, // Global flag
#[command(subcommand)]
pub command: Command,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Install(install::Install), // References the struct in install.rs
Search(search::Search),
// ... other commands
}
// src/cli/install.rs
use clap::Args;
// ... other imports
#[derive(Debug, Args)]
pub struct Install {
#[arg(required = true)]
names: Vec<String>, // Command-specific argument
#[arg(long)]
skip_deps: bool, // Command-specific flag
// ... other args
}
impl Install {
pub async fn run(&self, cfg: &Config, cache: Arc<Cache>) -> Result<()> {
// ... implementation using self.names, self.skip_deps etc.
}
}
Go beyond plain text to create a more engaging and informative CLI.
-
Colored Output (
colored
crate):- Use color to draw attention and convey status.
- Examples:
- Success messages:
.green()
- Error messages:
.red().bold()
- Informational headers:
.blue().bold()
- Package names/keywords:
.cyan()
- Success messages:
- Usage:
println!("{}", "Success!".green());
- Caution: Avoid excessive color. Ensure readability on different terminal themes.
-
Progress Indicators (
indicatif
crate):- Essential for long-running operations (downloads, installs, searches).
- Spinners: For tasks of indeterminate length. Use
ProgressBar::new_spinner()
. Wrap creation in a helper function (likeui::create_spinner
) for consistency.// src/ui.rs pub fn create_spinner(message: &str) -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.set_style(ProgressStyle::with_template("{spinner:.blue.bold} {msg}").unwrap()); pb.set_message(message.to_string()); pb.enable_steady_tick(Duration::from_millis(100)); pb } // Usage in command.rs let pb = ui::create_spinner("Fetching data..."); // ... perform task ... pb.finish_with_message("Data fetched!"); // or pb.finish_and_clear(); on error/completion without final message
- Progress Bars: For tasks with known steps or size (e.g., file downloads). Use
ProgressBar::new(total_steps)
. Update withpb.inc(1)
orpb.set_position(current)
.
-
Structured Output (
prettytable-rs
crate):- Use tables to display structured data clearly (e.g., search results, package info).
- Configure formatting (borders, alignment).
- Combine with
colored
for highlighting specific cells or rows. - Handle terminal width gracefully (see
search.rs
truncate_vis
example) to avoid messy wrapping.
-
User Prompts (
dialoguer
crate - Not insapphire-cli
example, but useful):- For interactive commands requiring user input (confirmations, selections).
- Provides functions for
confirm
,input
,select
, etc.
tracing
provides a powerful framework for instrumenting applications. It's superior to println!
for diagnostics.
- Levels: Log messages with different severity (
trace!
,debug!
,info!
,warn!
,error!
). - Structured Data: Log key-value pairs, not just strings.
- Filtering: Control log output based on level and source (module path) using
tracing_subscriber::EnvFilter
. Verbosity flags (-v
,-vv
) can easily control the filter level (seemain.rs
). - Spans: Trace the execution flow through different parts of your code.
- Context: Understand what the application is doing and why.
Setup (main.rs
):
use tracing::level_filters::LevelFilter;
use tracing_subscriber::EnvFilter;
// ... inside main ...
let level_filter = match cli_args.verbose {
0 => LevelFilter::INFO, // Default
1 => LevelFilter::DEBUG,
_ => LevelFilter::TRACE, // -vv or more
};
let env_filter = EnvFilter::builder()
.with_default_directive(level_filter.into())
.with_env_var("SAPPHIRE_LOG") // Allow override via env var
.from_env_lossy();
tracing_subscriber::fmt()
.with_env_filter(env_filter)
.with_writer(std::io::stderr) // Log to stderr
.init();
// ... later in code ...
tracing::info!("Starting installation for {}", package_name);
tracing::debug!(path = %path.display(), "Checking cache path");
if let Err(e) = some_operation() {
tracing::error!("Operation failed: {}", e);
}
Benefits:
- Clean separation of user output (stdout) and diagnostic output (stderr).
- Fine-grained control over log verbosity.
- Easier debugging of complex flows.
Graceful error handling is crucial for user trust.
- Use
Result
: ReturnResult<T, E>
from functions that can fail. The?
operator propagates errors concisely. - Choose an Error Strategy:
anyhow
: Good for application-level errors where you primarily need a simpleResult
and good error messages with backtraces.thiserror
: Ideal for defining custom error enums, especially in libraries or when you need to match specific error types programmatically.sapphire-core
likely usesthiserror
forSapphireError
.
- Provide Context: Error messages should explain what failed and why (if possible). Avoid generic errors.
- User-Friendly Messages: Display errors clearly to the user (e.g., using
eprintln!
andcolored
). Don't just panic.
Example (main.rs
):
// In main, catching errors from command execution
if let Err(e) = cli_args.command.run(&config, cache).await {
eprintln!("{}: {:#}", "Error".red().bold(), e); // Pretty print the error
process::exit(1);
}
// In a command function, propagating errors
async fn some_task() -> Result<()> { // Assuming Result = anyhow::Result or std::result::Result<_, CustomError>
let data = fetch_data().await?; // Propagate error if fetch_data fails
process_data(data)?; // Propagate error if process_data fails
Ok(())
}
For CLI tools involving I/O (network requests, file system operations), async
/await
with tokio
is essential for responsiveness.
- Mark functions with
async fn
. - Use
.await
when calling otherasync
functions. - Use
#[tokio::main]
on yourmain
function. - Leverage async libraries (e.g.,
reqwest
for HTTP,tokio::fs
for files). - Use
tokio::spawn
for concurrency (like ininstall.rs
for parallel downloads/installs), often combined withtokio::sync::Semaphore
to limit parallelism.
Building a great Rust CLI involves combining good structure (clap
, modules), informative feedback (indicatif
, colored
, prettytable
), robust diagnostics (tracing
), solid error handling (Result
, anyhow
/thiserror
), and efficient I/O (tokio
). By following these principles and learning from examples like sapphire-cli
, you can create tools that are both powerful and pleasant to use.