Keyboard shortcuts

Press or to navigate between chapters

Press ? to show this help

Press Esc to hide this help

Adding Commands

Three patterns, from simplest to most complex.

Pattern A: Simple Command (2 files)

Add a count command that counts lines in a file.

1. Add variant to Command enum

src/cli/mod.rs

#![allow(unused)]
fn main() {
/// Count lines in a file
Count {
    /// Path to the file
    path: std::path::PathBuf,
    /// Skip empty lines
    #[arg(long)]
    no_empty: bool,
},
}

2. Add dispatch

src/main.rs

#![allow(unused)]
fn main() {
Command::Count { path, no_empty } => {
    let content = std::fs::read_to_string(&path).context(IoSnafu)?;
    let count = if no_empty {
        content.lines().filter(|l| !l.trim().is_empty()).count()
    } else {
        content.lines().count()
    };
    eprintln!("{count} lines in {}", path.display());
    println!("{}", serde_json::json!({"ok": true, "action": "count", "lines": count}));
}
}

That’s it. Two files, done.


Pattern B: Config-Dependent Command

Everything from Pattern A, plus config integration.

1. Define config struct

src/app_config.rs

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadConfig {
    /// Directory to save downloaded files
    #[serde(default = "default_output_dir")]
    pub output_dir: String,

    /// Maximum concurrent downloads
    #[serde(default = "default_max_concurrent")]
    pub max_concurrent: usize,
}

fn default_output_dir() -> String {
    "./downloads".to_string()
}

fn default_max_concurrent() -> usize {
    4
}

impl Default for DownloadConfig {
    fn default() -> Self {
        Self {
            output_dir: default_output_dir(),
            max_concurrent: default_max_concurrent(),
        }
    }
}
}

2. Add field to AppConfig

#![allow(unused)]
fn main() {
pub struct AppConfig {
    // ... existing fields ...
    #[serde(default)]
    pub download: DownloadConfig,
}
}

3. Wire config accessors

In set_config_field:

#![allow(unused)]
fn main() {
"download.output_dir" => {
    self.download.output_dir = value.to_string();
}
"download.max_concurrent" => {
    self.download.max_concurrent = value.parse().context(ParseIntSnafu)?;
}
}

In get_config_field:

#![allow(unused)]
fn main() {
"download.output_dir" => Some(self.download.output_dir.clone()),
"download.max_concurrent" => Some(self.download.max_concurrent.to_string()),
}

In config_as_map:

#![allow(unused)]
fn main() {
map.insert("download.output_dir".into(), self.download.output_dir.clone());
map.insert("download.max_concurrent".into(), self.download.max_concurrent.to_string());
}

4. Add command variant and dispatch

Same as Pattern A. Access config via config.download.output_dir.


Pattern C: HTTP Command

Everything from Pattern A, plus an HTTP call using the shared client.

Command variant

#![allow(unused)]
fn main() {
/// Fetch data from a URL
Fetch {
    /// Target URL
    url: String,
},
}

Dispatch

#![allow(unused)]
fn main() {
Command::Fetch { url } => {
    let resp = http::client()
        .get(&url)
        .send().await.context(HttpSnafu)?
        .json::<serde_json::Value>().await.context(HttpSnafu)?;
    println!("{}", serde_json::to_string_pretty(&resp).context(SerializeSnafu)?);
}
}

http::client() returns a pre-configured reqwest::Client with timeouts and default headers already set.


Extracting into a Module

When command logic outgrows the dispatch block, extract it.

File structure

src/yourmodule/
  mod.rs       # //! doc + re-exports
  client.rs    # error enum + logic

src/yourmodule/mod.rs

#![allow(unused)]
fn main() {
//! Your module — does X, Y, Z.

mod client;

pub use client::{YourModuleClient, YourModuleError};
}

src/yourmodule/client.rs

#![allow(unused)]
fn main() {
use snafu::{ResultExt, Snafu};

#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
pub enum YourModuleError {
    #[snafu(display("request failed: {source}"))]
    Request { source: reqwest::Error },
}

pub type Result<T> = std::result::Result<T, YourModuleError>;

/// Client for interacting with the service.
pub struct YourModuleClient {
    http: reqwest::Client,
}

impl YourModuleClient {
    pub fn new(http: reqwest::Client) -> Self {
        Self { http }
    }

    pub async fn do_thing(&self) -> Result<()> {
        // ...
        Ok(())
    }
}
}

Register in src/lib.rs

#![allow(unused)]
fn main() {
pub mod yourmodule;
}

Add error variant to src/error.rs

#![allow(unused)]
fn main() {
#[snafu(display("yourmodule error: {source}"))]
YourModule { source: yourmodule::YourModuleError },
}

Checklist

Before opening a PR, verify:

  • Module has //! doc comment
  • Error enum named {Module}Error with #[derive(Debug, Snafu)]
  • All pub items have /// doc comments
  • Module registered in src/lib.rs
  • Structs with 3+ fields use #[derive(bon::Builder)]