Skip to content

Instantly share code, notes, and snippets.

@g1ibby
Created April 23, 2025 03:29
Show Gist options
  • Save g1ibby/786cc16cc981090abb6692d5d40a6e1b to your computer and use it in GitHub Desktop.
Save g1ibby/786cc16cc981090abb6692d5d40a6e1b to your computer and use it in GitHub Desktop.
Guide: Building Beautiful & User-Friendly Rust CLI Tools

Guide: Building Beautiful & User-Friendly Rust CLI Tools

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.

Core Principles of a Good CLI

  • 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.

1. Project Structure & Organization

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 like Cache.
    • 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 the clap::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 the Command enum and calls the specific command's run.
  • 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.
  • 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.
  • src/error.rs: (If using custom errors)

    • Defines the application's custom error enum, often using thiserror.

Benefits:

  • Clear separation of concerns.
  • Easy to locate code for specific commands.
  • Reduces complexity in main.rs and cli.rs.

2. Argument Parsing with clap

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.
    }
}

3. Enhancing User Experience (UI)

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()
    • 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 (like ui::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 with pb.inc(1) or pb.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 in sapphire-cli example, but useful):

    • For interactive commands requiring user input (confirmations, selections).
    • Provides functions for confirm, input, select, etc.

4. Logging and Diagnostics with tracing

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 (see main.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.

5. Robust Error Handling

Graceful error handling is crucial for user trust.

  • Use Result: Return Result<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 simple Result 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 uses thiserror for SapphireError.
  • 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! and colored). 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(())
}

6. Asynchronous Operations (tokio)

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 other async functions.
  • Use #[tokio::main] on your main function.
  • Leverage async libraries (e.g., reqwest for HTTP, tokio::fs for files).
  • Use tokio::spawn for concurrency (like in install.rs for parallel downloads/installs), often combined with tokio::sync::Semaphore to limit parallelism.

Conclusion

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment