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
runmethod.
-
src/cli.rs:- Root CLI Definition: Defines the main
clap::Parserstruct (e.g.,CliArgs) and theclap::Subcommandenum (e.g.,Command). - Submodule Aggregation: Declares
modfor each command submodule (e.g.,mod install;). - Central Dispatch Logic: Often contains the top-level
runmethod that matches on theCommandenum 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::Argsstruct 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.rsandcli.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
--helpand--versionmessages 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 (
coloredcrate):- 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 (
indicatifcrate):- 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-rscrate):- Use tables to display structured data clearly (e.g., search results, package info).
- Configure formatting (borders, alignment).
- Combine with
coloredfor highlighting specific cells or rows. - Handle terminal width gracefully (see
search.rstruncate_visexample) to avoid messy wrapping.
-
User Prompts (
dialoguercrate - Not insapphire-cliexample, 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 simpleResultand 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-corelikely usesthiserrorforSapphireError.
- 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
.awaitwhen calling otherasyncfunctions. - Use
#[tokio::main]on yourmainfunction. - Leverage async libraries (e.g.,
reqwestfor HTTP,tokio::fsfor files). - Use
tokio::spawnfor concurrency (like ininstall.rsfor parallel downloads/installs), often combined withtokio::sync::Semaphoreto 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.